Reset data bị lỗi, thêm hotspot
This commit is contained in:
@@ -0,0 +1,33 @@
|
|||||||
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
|
const hotspotSchema = new mongoose.Schema({
|
||||||
|
parent_scene_id: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Scene',
|
||||||
|
required: true
|
||||||
|
},
|
||||||
|
target_scene_id: {
|
||||||
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
|
ref: 'Scene'
|
||||||
|
},
|
||||||
|
title: {
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
|
coordinates: {
|
||||||
|
yaw: { type: Number, required: true },
|
||||||
|
pitch: { type: Number, required: true }
|
||||||
|
},
|
||||||
|
is_auto_return: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
}, {
|
||||||
|
timestamps: true
|
||||||
|
});
|
||||||
|
|
||||||
|
module.exports = mongoose.model('Hotspot', hotspotSchema);
|
||||||
+8
-31
@@ -1,25 +1,24 @@
|
|||||||
const mongoose = require('mongoose');
|
const mongoose = require('mongoose');
|
||||||
|
|
||||||
const sceneSchema = new mongoose.Schema({
|
const sceneSchema = new mongoose.Schema({
|
||||||
title: {
|
name: {
|
||||||
type: String,
|
type: String,
|
||||||
required: true,
|
required: true,
|
||||||
trim: true
|
trim: true
|
||||||
},
|
},
|
||||||
|
scene_url: {
|
||||||
|
type: String
|
||||||
|
},
|
||||||
assetId: {
|
assetId: {
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Asset',
|
ref: 'Asset',
|
||||||
required: true
|
required: true
|
||||||
},
|
},
|
||||||
lat: {
|
gps: {
|
||||||
type: Number,
|
lat: { type: Number, required: true },
|
||||||
required: true
|
lng: { type: Number, required: true }
|
||||||
},
|
},
|
||||||
lng: {
|
createdBy: {
|
||||||
type: Number,
|
|
||||||
required: true
|
|
||||||
},
|
|
||||||
owner: {
|
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'User',
|
ref: 'User',
|
||||||
required: true
|
required: true
|
||||||
@@ -38,28 +37,6 @@ const sceneSchema = new mongoose.Schema({
|
|||||||
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
|
||||||
});
|
});
|
||||||
|
|||||||
+143
-88
@@ -7,6 +7,7 @@ const crypto = require('crypto');
|
|||||||
const User = require('../models/User');
|
const User = require('../models/User');
|
||||||
const Asset = require('../models/Asset');
|
const Asset = require('../models/Asset');
|
||||||
const Scene = require('../models/Scene');
|
const Scene = require('../models/Scene');
|
||||||
|
const Hotspot = require('../models/Hotspot'); // Giả định bạn đã tạo model mới
|
||||||
|
|
||||||
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
const { protect, optionalAuth } = require('../middlewares/authMiddleware');
|
||||||
const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware');
|
const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware');
|
||||||
@@ -77,8 +78,8 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
return res.status(400).json({ message: 'Please upload a panorama image' });
|
return res.status(400).json({ message: 'Please upload a panorama image' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const latitude = parseFloat(lat);
|
const latitude = Number(lat);
|
||||||
const longitude = parseFloat(lng);
|
const longitude = Number(lng);
|
||||||
|
|
||||||
if (isNaN(latitude) || isNaN(longitude)) {
|
if (isNaN(latitude) || isNaN(longitude)) {
|
||||||
// Cleanup uploaded file on validation error
|
// Cleanup uploaded file on validation error
|
||||||
@@ -134,11 +135,14 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
|
|
||||||
// 7. Save Scene to DB
|
// 7. Save Scene to DB
|
||||||
const scene = new Scene({
|
const scene = new Scene({
|
||||||
title,
|
name: title,
|
||||||
assetId: asset._id,
|
assetId: asset._id,
|
||||||
lat: latitude,
|
scene_url: processedFilePath, // Lưu đường dẫn ảnh trực tiếp
|
||||||
lng: longitude,
|
gps: {
|
||||||
owner: req.user._id,
|
lat: latitude,
|
||||||
|
lng: longitude
|
||||||
|
},
|
||||||
|
createdBy: req.user._id,
|
||||||
privacy: privacy || 'private',
|
privacy: privacy || 'private',
|
||||||
shareToken,
|
shareToken,
|
||||||
sharedWith: parsedSharedWith
|
sharedWith: parsedSharedWith
|
||||||
@@ -176,7 +180,7 @@ router.get('/scenes', optionalAuth, async (req, res) => {
|
|||||||
{ privacy: 'public' },
|
{ privacy: 'public' },
|
||||||
{ privacy: 'member' },
|
{ privacy: 'member' },
|
||||||
{ privacy: 'shared' }, // shareToken will be required to fetch panorama, but coordinates show on map
|
{ privacy: 'shared' }, // shareToken will be required to fetch panorama, but coordinates show on map
|
||||||
{ owner: req.user._id },
|
{ createdBy: req.user._id },
|
||||||
{ sharedWith: req.user._id }
|
{ sharedWith: req.user._id }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
@@ -191,8 +195,8 @@ router.get('/scenes', optionalAuth, async (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const scenes = await Scene.find(query)
|
const scenes = await Scene.find(query)
|
||||||
.populate('owner', 'username')
|
.populate('createdBy', 'username')
|
||||||
.populate('assetId', 'coordinates createdAt');
|
.lean();
|
||||||
|
|
||||||
console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`);
|
console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`);
|
||||||
res.json(scenes);
|
res.json(scenes);
|
||||||
@@ -210,7 +214,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.params.id)
|
const scene = await Scene.findById(req.params.id)
|
||||||
.populate('owner', 'username')
|
.populate('createdBy', 'username')
|
||||||
.populate('assetId');
|
.populate('assetId');
|
||||||
|
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
@@ -220,7 +224,7 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
|||||||
const hasAccess =
|
const hasAccess =
|
||||||
scene.privacy === 'public' ||
|
scene.privacy === 'public' ||
|
||||||
(scene.privacy === 'member' && req.user) ||
|
(scene.privacy === 'member' && req.user) ||
|
||||||
(req.user && scene.owner._id.toString() === req.user._id.toString()) ||
|
(req.user && scene.createdBy._id.toString() === req.user._id.toString()) ||
|
||||||
(req.user && scene.sharedWith.includes(req.user._id)) ||
|
(req.user && scene.sharedWith.includes(req.user._id)) ||
|
||||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken);
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken);
|
||||||
|
|
||||||
@@ -235,83 +239,130 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route POST /api/scenes/:id/hotspots
|
* @route GET /api/hotspots/:scene_id
|
||||||
* @desc Add a new hotspot to a scene
|
* @desc Lấy toàn bộ danh sách hotspot của scene hiện tại
|
||||||
* @access Private (Owner only)
|
|
||||||
*/
|
*/
|
||||||
router.post('/scenes/:id/hotspots', protect, async (req, res) => {
|
router.get('/hotspots/:scene_id', async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const { hotspotId, pitch, yaw, text, description, targetSceneId } = req.body;
|
const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_id });
|
||||||
const scene = await Scene.findById(req.params.id);
|
res.json(hotspots);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
if (!scene) {
|
/**
|
||||||
return res.status(404).json({ message: 'Scene not found' });
|
* @route POST /api/hotspots/create
|
||||||
|
* @desc Tạo mới Hotspot + Tự động tạo liên kết ngược
|
||||||
|
*/
|
||||||
|
router.post('/hotspots/create', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { parent_scene_id, target_scene_id, title, description, coordinates } = req.body;
|
||||||
|
|
||||||
|
const parentScene = await Scene.findById(parent_scene_id);
|
||||||
|
// Phân quyền: Admin hoặc Người tạo Scene
|
||||||
|
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
|
||||||
|
if (!parentScene || !isAuthorized) {
|
||||||
|
return res.status(403).json({ message: 'Không có quyền tạo hotspot cho scene này' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Chỉ chủ sở hữu mới có quyền chỉnh sửa hotspots
|
const hotspot = new Hotspot({
|
||||||
if (scene.owner.toString() !== req.user._id.toString()) {
|
parent_scene_id,
|
||||||
return res.status(403).json({ message: 'Access denied: Only the owner can add hotspots' });
|
target_scene_id,
|
||||||
}
|
title,
|
||||||
|
description,
|
||||||
if (hotspotId) {
|
coordinates: {
|
||||||
// CẬP NHẬT HOTSPOT HIỆN CÓ
|
yaw: Number(coordinates.yaw),
|
||||||
const hs = scene.hotspots.id(hotspotId);
|
pitch: Number(coordinates.pitch)
|
||||||
if (!hs) return res.status(404).json({ message: 'Hotspot not found' });
|
},
|
||||||
|
is_auto_return: false
|
||||||
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();
|
|
||||||
|
|
||||||
// LOGIC "MẸ - CON": TỰ ĐỘNG TẠO ĐIỂM QUAY LẠI
|
|
||||||
if (targetSceneId && targetSceneId !== 'null' && targetSceneId !== '' && typeof targetSceneId === 'string') {
|
|
||||||
try {
|
|
||||||
const targetScene = await Scene.findById(targetSceneId);
|
|
||||||
if (targetScene) {
|
|
||||||
const hasReverse = targetScene.hotspots.some(h =>
|
|
||||||
h.targetSceneId && h.targetSceneId.toString() === scene._id.toString()
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!hasReverse) {
|
|
||||||
const originYaw = parseFloat(yaw) || 0;
|
|
||||||
const reverseYaw = originYaw > 0 ? originYaw - 180 : originYaw + 180;
|
|
||||||
|
|
||||||
targetScene.hotspots.push({
|
|
||||||
pitch: 0,
|
|
||||||
yaw: reverseYaw,
|
|
||||||
text: `Quay lại: ${scene.title}`,
|
|
||||||
targetSceneId: scene._id
|
|
||||||
});
|
|
||||||
await targetScene.save();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
console.error("Lỗi tạo hotspot ngược:", err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
res.status(201).json({
|
|
||||||
message: 'Hotspot added successfully',
|
|
||||||
hotspots: scene.hotspots
|
|
||||||
});
|
});
|
||||||
|
await hotspot.save();
|
||||||
|
|
||||||
|
if (target_scene_id) {
|
||||||
|
const targetScene = await Scene.findById(target_scene_id);
|
||||||
|
if (targetScene) {
|
||||||
|
const reverseYaw = coordinates.yaw > 0 ? coordinates.yaw - 180 : coordinates.yaw + 180;
|
||||||
|
|
||||||
|
// Fallback đa tầng cho tiêu đề quay lại
|
||||||
|
const backLabel = title || parentScene.name || parentScene.title || 'cảnh trước';
|
||||||
|
|
||||||
|
const reverseHotspot = new Hotspot({
|
||||||
|
parent_scene_id: target_scene_id,
|
||||||
|
target_scene_id: parent_scene_id,
|
||||||
|
title: `Quay lại ${backLabel}`,
|
||||||
|
coordinates: { yaw: reverseYaw, pitch: 0 },
|
||||||
|
is_auto_return: true
|
||||||
|
});
|
||||||
|
await reverseHotspot.save();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
res.status(201).json(hotspot);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route PUT /api/hotspots/update/:id
|
||||||
|
* @desc Cập nhật hotspot
|
||||||
|
*/
|
||||||
|
router.put('/hotspots/update/:id', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { title, description, coordinates } = req.body;
|
||||||
|
const hotspot = await Hotspot.findById(req.params.id);
|
||||||
|
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
|
||||||
|
|
||||||
|
const parentScene = await Scene.findById(hotspot.parent_scene_id);
|
||||||
|
// Phân quyền Admin hoặc Owner
|
||||||
|
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return res.status(403).json({ message: 'Không có quyền cập nhật' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (title) hotspot.title = title;
|
||||||
|
if (description) hotspot.description = description;
|
||||||
|
if (coordinates) {
|
||||||
|
hotspot.coordinates = {
|
||||||
|
yaw: Number(coordinates.yaw),
|
||||||
|
pitch: Number(coordinates.pitch)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
await hotspot.save();
|
||||||
|
res.json(hotspot);
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/hotspots/delete/:id
|
||||||
|
* @desc Xóa hotspot + Xóa luôn hotspot ngược tương ứng
|
||||||
|
*/
|
||||||
|
router.delete('/hotspots/delete/:id', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const hotspot = await Hotspot.findById(req.params.id);
|
||||||
|
if (!hotspot) return res.status(404).json({ message: 'Hotspot không tồn tại' });
|
||||||
|
|
||||||
|
const parentScene = await Scene.findById(hotspot.parent_scene_id);
|
||||||
|
// Phân quyền Admin hoặc Owner
|
||||||
|
const isAuthorized = req.user.role === 'Chủ sở hữu' || (parentScene && parentScene.createdBy.toString() === req.user._id.toString());
|
||||||
|
if (!isAuthorized) {
|
||||||
|
return res.status(403).json({ message: 'Không có quyền xóa' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hotspot.target_scene_id) {
|
||||||
|
await Hotspot.deleteOne({
|
||||||
|
parent_scene_id: hotspot.target_scene_id,
|
||||||
|
target_scene_id: hotspot.parent_scene_id,
|
||||||
|
is_auto_return: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await Hotspot.findByIdAndDelete(req.params.id);
|
||||||
|
res.json({ message: 'Hotspot deleted successfully' });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
@@ -340,7 +391,7 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res
|
|||||||
const hasAccess =
|
const hasAccess =
|
||||||
scene.privacy === 'public' ||
|
scene.privacy === 'public' ||
|
||||||
(scene.privacy === 'member' && req.user) ||
|
(scene.privacy === 'member' && req.user) ||
|
||||||
(req.user && scene.owner.toString() === req.user._id.toString()) ||
|
(req.user && scene.createdBy.toString() === req.user._id.toString()) ||
|
||||||
(req.user && scene.sharedWith.includes(req.user._id)) ||
|
(req.user && scene.sharedWith.includes(req.user._id)) ||
|
||||||
(scene.privacy === 'shared' && req.query.token === scene.shareToken);
|
(scene.privacy === 'shared' && req.query.token === scene.shareToken);
|
||||||
|
|
||||||
@@ -381,15 +432,17 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
const { title, privacy, sharedWithUsers, lat, lng } = req.body;
|
const { title, privacy, sharedWithUsers, lat, lng } = req.body;
|
||||||
const scene = await Scene.findById(req.params.id);
|
const scene = await Scene.findById(req.params.id);
|
||||||
|
|
||||||
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
|
// Phân quyền Admin hoặc Owner
|
||||||
|
const isAuthorized = req.user.role === 'Chủ sở hữu' || (scene && scene.createdBy.toString() === req.user._id.toString());
|
||||||
|
if (!scene || !isAuthorized) {
|
||||||
return res.status(403).json({ message: 'Not authorized' });
|
return res.status(403).json({ message: 'Not authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update basic info
|
// Update basic info
|
||||||
scene.title = title || scene.title;
|
scene.name = title || scene.name;
|
||||||
scene.privacy = privacy || scene.privacy;
|
scene.privacy = privacy || scene.privacy;
|
||||||
scene.lat = lat ? parseFloat(lat) : scene.lat;
|
if (lat) scene.gps.lat = Number(lat);
|
||||||
scene.lng = lng ? parseFloat(lng) : scene.lng;
|
if (lng) scene.gps.lng = Number(lng);
|
||||||
|
|
||||||
if (privacy === 'shared' && !scene.shareToken) {
|
if (privacy === 'shared' && !scene.shareToken) {
|
||||||
scene.shareToken = crypto.randomBytes(24).toString('hex');
|
scene.shareToken = crypto.randomBytes(24).toString('hex');
|
||||||
@@ -428,7 +481,9 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
router.delete('/scenes/:id', protect, async (req, res) => {
|
router.delete('/scenes/:id', protect, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const scene = await Scene.findById(req.params.id);
|
const scene = await Scene.findById(req.params.id);
|
||||||
if (!scene || scene.owner.toString() !== req.user._id.toString()) {
|
// Phân quyền Admin hoặc Owner
|
||||||
|
const isAuthorized = req.user.role === 'Chủ sở hữu' || (scene && scene.createdBy.toString() === req.user._id.toString());
|
||||||
|
if (!scene || !isAuthorized) {
|
||||||
return res.status(403).json({ message: 'Not authorized' });
|
return res.status(403).json({ message: 'Not authorized' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 5.2 MiB |
BIN
Binary file not shown.
|
Before Width: | Height: | Size: 6.2 MiB After Width: | Height: | Size: 6.2 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.1 MiB |
+11
-1
@@ -74,7 +74,7 @@ html, body {
|
|||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
background-color: rgba(0, 0, 0, 0.5);
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
z-index: 2000;
|
z-index: 4000;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
}
|
}
|
||||||
@@ -170,6 +170,16 @@ html, body {
|
|||||||
#close-viewer-btn:hover {
|
#close-viewer-btn:hover {
|
||||||
background: white;
|
background: white;
|
||||||
}
|
}
|
||||||
|
/* Modal Overlay */
|
||||||
|
.modal-overlay {
|
||||||
|
display: none;
|
||||||
|
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: 4000;
|
||||||
|
}
|
||||||
|
|
||||||
.modal-content {
|
.modal-content {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
|
|||||||
+51
-16
@@ -104,6 +104,22 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Hotspot Action Choice Modal -->
|
||||||
|
<div id="hotspot-action-modal" class="modal">
|
||||||
|
<div class="modal-content action-modal-content">
|
||||||
|
<span class="close-btn" onclick="closeHotspotActionModal()">×</span>
|
||||||
|
<h2 id="hs-action-title">Tùy chọn Hotspot</h2>
|
||||||
|
<div class="action-buttons">
|
||||||
|
<button id="btn-hs-edit" class="edit-btn-large">
|
||||||
|
<span class="icon">✏️</span> Chỉnh sửa Hotspot
|
||||||
|
</button>
|
||||||
|
<button id="btn-hs-delete" class="delete-btn-large">
|
||||||
|
<span class="icon">🗑️</span> Xóa Hotspot
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- 3D Panorama Viewer Container -->
|
<!-- 3D Panorama Viewer Container -->
|
||||||
<div id="viewer-container" style="display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; z-index: 2000; background: #000;">
|
<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>
|
||||||
@@ -134,15 +150,21 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group divider">
|
<div class="form-group divider">
|
||||||
<label>Liên kết tới ảnh 3D khác:</label>
|
<label>Kiểu liên kết:</label>
|
||||||
<div class="tab-container">
|
<div class="radio-group">
|
||||||
<button type="button" class="tab-btn active" onclick="switchHSTab('select')">Chọn ảnh có sẵn</button>
|
<label class="radio-item">
|
||||||
<button type="button" class="tab-btn" onclick="switchHSTab('upload')">Tải ảnh mới lên</button>
|
<input type="radio" name="hsLinkType" value="existing" checked onclick="toggleHSLinkType('existing')">
|
||||||
|
Chọn từ ảnh có sẵn
|
||||||
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="hsLinkType" value="upload" onclick="toggleHSLinkType('upload')">
|
||||||
|
Upload ảnh mới
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 1: Chọn ảnh có sẵn -->
|
<!-- Lựa chọn B: Chọn ảnh có sẵn -->
|
||||||
<div id="hs-tab-select" class="tab-content">
|
<div id="hs-section-existing" class="tab-content">
|
||||||
<label for="hs-target-id">Danh sách Scene của bạn:</label>
|
<label for="hs-target-id">Danh sách Scene của bạn:</label>
|
||||||
<select id="hs-target-id" name="targetSceneId">
|
<select id="hs-target-id" name="targetSceneId">
|
||||||
<option value="">-- Chọn một cảnh để liên kết --</option>
|
<option value="">-- Chọn một cảnh để liên kết --</option>
|
||||||
@@ -150,22 +172,35 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab 2: Tải ảnh mới -->
|
<!-- Lựa chọn A: Tải ảnh mới -->
|
||||||
<div id="hs-tab-upload" class="tab-content" style="display: none;">
|
<div id="hs-section-upload" class="tab-content" style="display: none;">
|
||||||
<label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label>
|
<label for="hs-panorama-file">Chọn ảnh Panorama 360°:</label>
|
||||||
<input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*">
|
<input type="file" id="hs-panorama-file" name="panorama-file" accept="image/*">
|
||||||
|
|
||||||
<div class="gps-inheritance">
|
<div class="gps-inheritance">
|
||||||
<label>Xử lý GPS cho ảnh mới:</label>
|
<label>Xử lý GPS cho ảnh mới:</label>
|
||||||
<select id="hs-gps-mode" onchange="toggleManualGPS()">
|
<div class="radio-group" style="flex-direction: column; gap: 5px;">
|
||||||
<option value="auto">Đọc từ EXIF ảnh (Mặc định)</option>
|
<label class="radio-item">
|
||||||
<option value="inherit">Lấy GPS của cảnh hiện tại</option>
|
<input type="radio" name="hsGPSMode" value="map" checked onclick="toggleHSGPSMode('map')">
|
||||||
<option value="manual">Nhập thủ công</option>
|
Map Selection (Chọn trên bản đồ con)
|
||||||
</select>
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="hsGPSMode" value="manual" onclick="toggleHSGPSMode('manual')">
|
||||||
|
Manual Input (Nhập thủ công)
|
||||||
|
</label>
|
||||||
|
<label class="radio-item">
|
||||||
|
<input type="radio" name="hsGPSMode" value="inherit" onclick="toggleHSGPSMode('inherit')">
|
||||||
|
Inherit Parent (Kế thừa từ cảnh hiện tại)
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="hs-map-selector" style="margin-top: 10px;">
|
||||||
|
<div id="hs-mini-map"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="hs-manual-gps" style="display: none; margin-top: 10px;">
|
<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-lat" name="hs-lat" placeholder="Latitude">
|
||||||
<input type="number" step="any" id="hs-lng" placeholder="Longitude">
|
<input type="number" step="any" id="hs-lng" name="hs-lng" placeholder="Longitude">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+241
-91
@@ -5,6 +5,8 @@ let tempMarker = null;
|
|||||||
let markerClusterGroup;
|
let markerClusterGroup;
|
||||||
let currentSceneId = null;
|
let currentSceneId = null;
|
||||||
let previousSceneId = null;
|
let previousSceneId = null;
|
||||||
|
let miniMap = null;
|
||||||
|
let miniMapMarker = null;
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -66,11 +68,17 @@ function initMap() {
|
|||||||
iconCreateFunction: function(cluster) {
|
iconCreateFunction: function(cluster) {
|
||||||
const childMarkers = cluster.getAllChildMarkers();
|
const childMarkers = cluster.getAllChildMarkers();
|
||||||
try {
|
try {
|
||||||
// Thay vì tạo object mới phức tạp, lấy HTML của marker đầu tiên
|
if (childMarkers.length > 0 && childMarkers[0].options.icon) {
|
||||||
const firstIcon = childMarkers[0].options.icon;
|
const childIcon = childMarkers[0].options.icon;
|
||||||
if (firstIcon) return firstIcon;
|
// Trả về một DivIcon MỚI dựa trên cấu hình của con, tránh dùng chung instance gây crash render
|
||||||
|
return L.divIcon({
|
||||||
|
html: childIcon.options.html,
|
||||||
|
className: childIcon.options.className,
|
||||||
|
iconSize: childIcon.options.iconSize,
|
||||||
|
iconAnchor: childIcon.options.iconAnchor
|
||||||
|
});
|
||||||
|
}
|
||||||
} catch (e) {}
|
} catch (e) {}
|
||||||
// Fallback an toàn nếu có lỗi
|
|
||||||
return L.divIcon({ className: 'cluster-fallback', html: '<div style="background:#007bff;width:10px;height:10px;border-radius:50%;"></div>' });
|
return L.divIcon({ className: 'cluster-fallback', html: '<div style="background:#007bff;width:10px;height:10px;border-radius:50%;"></div>' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -99,6 +107,11 @@ function initMap() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Chỉ Admin (Chủ sở hữu) mới có quyền tạo Scene mới trực tiếp trên map qua contextmenu
|
||||||
|
const userRole = localStorage.getItem('role');
|
||||||
|
const isAdmin = userRole === 'Chủ sở hữu' || userRole === 'admin';
|
||||||
|
if (!isAdmin) return;
|
||||||
|
|
||||||
const { lat, lng } = e.latlng;
|
const { lat, lng } = e.latlng;
|
||||||
openCreateSceneModal(lat, lng);
|
openCreateSceneModal(lat, lng);
|
||||||
});
|
});
|
||||||
@@ -357,8 +370,9 @@ async function loadScenes() {
|
|||||||
|
|
||||||
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
|
// Chỉ lặp qua danh sách Scene mẹ, lọc bỏ các hotspots trùng tọa độ
|
||||||
scenes.forEach((scene) => {
|
scenes.forEach((scene) => {
|
||||||
const latNum = parseFloat(scene.lat);
|
// Ép kiểu tọa độ về Number để tránh lỗi render bản đồ
|
||||||
const lngNum = parseFloat(scene.lng);
|
const latNum = Number(scene.gps?.lat || scene.lat);
|
||||||
|
const lngNum = Number(scene.gps?.lng || scene.lng);
|
||||||
|
|
||||||
if (isNaN(latNum) || isNaN(lngNum)) return;
|
if (isNaN(latNum) || isNaN(lngNum)) return;
|
||||||
|
|
||||||
@@ -368,12 +382,10 @@ async function loadScenes() {
|
|||||||
seenCoordinates.add(coordKey);
|
seenCoordinates.add(coordKey);
|
||||||
|
|
||||||
// Kiểm tra an toàn dữ liệu từ MongoDB trước khi truy cập
|
// Kiểm tra an toàn dữ liệu từ MongoDB trước khi truy cập
|
||||||
if (!scene.assetId || !scene.assetId._id) {
|
const assetId = scene.assetId?._id || scene.assetId;
|
||||||
console.warn(`Scene "${scene.title}" thiếu dữ liệu ảnh (AssetId), bỏ qua.`);
|
const sceneName = scene.name || scene.title;
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let thumbUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
|
let thumbUrl = `${API_BASE_URL}/assets/view/${assetId}`;
|
||||||
if (token) thumbUrl += `?token=${token}`;
|
if (token) thumbUrl += `?token=${token}`;
|
||||||
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
|
else if (scene.privacy === 'shared' && scene.shareToken) thumbUrl += `?token=${scene.shareToken}`;
|
||||||
|
|
||||||
@@ -382,7 +394,7 @@ async function loadScenes() {
|
|||||||
html: `
|
html: `
|
||||||
<div class="scene-callout">
|
<div class="scene-callout">
|
||||||
<div class="scene-img-wrapper">
|
<div class="scene-img-wrapper">
|
||||||
<img src="${thumbUrl}" alt="${scene.title}">
|
<img src="${thumbUrl}" alt="${sceneName}">
|
||||||
</div>
|
</div>
|
||||||
</div>`,
|
</div>`,
|
||||||
iconSize: [64, 64],
|
iconSize: [64, 64],
|
||||||
@@ -391,16 +403,16 @@ async function loadScenes() {
|
|||||||
|
|
||||||
const marker = L.marker([latNum, lngNum], {
|
const marker = L.marker([latNum, lngNum], {
|
||||||
icon: calloutIcon,
|
icon: calloutIcon,
|
||||||
title: scene.title // Tooltip khi di chuột qua
|
title: sceneName
|
||||||
});
|
});
|
||||||
|
|
||||||
// Tạo nội dung thông tin khi Hover (Tooltip)
|
// Tạo nội dung thông tin khi Hover (Tooltip)
|
||||||
const createdDate = scene.assetId?.createdAt ? new Date(scene.assetId.createdAt).toLocaleDateString('vi-VN') : 'N/A';
|
const createdDate = scene.assetId?.createdAt ? new Date(scene.assetId.createdAt).toLocaleDateString('vi-VN') : 'N/A';
|
||||||
const tooltipContent = `
|
const tooltipContent = `
|
||||||
<div class="scene-hover-info">
|
<div class="scene-hover-info">
|
||||||
<strong>${scene.title}</strong><br>
|
<strong>${sceneName}</strong><br>
|
||||||
${scene.description ? `<small>${scene.description}</small><br>` : ''}
|
${scene.description ? `<small>${scene.description}</small><br>` : ''}
|
||||||
<span>Người tạo: ${scene.owner ? scene.owner.username : 'Ẩn danh'}</span><br>
|
<span>Người tạo: ${scene.createdBy ? scene.createdBy.username : 'Ẩn danh'}</span><br>
|
||||||
<span>Ngày tạo: ${createdDate}</span>
|
<span>Ngày tạo: ${createdDate}</span>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -423,9 +435,13 @@ async function loadScenes() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const currentUserId = localStorage.getItem('userId');
|
const currentUserId = localStorage.getItem('userId');
|
||||||
const ownerId = scene.owner?._id || scene.owner;
|
const userRole = localStorage.getItem('role');
|
||||||
|
// Hỗ trợ cả schema cũ (owner) và mới (createdBy)
|
||||||
|
const ownerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
|
||||||
|
|
||||||
if (currentUserId && ownerId && ownerId.toString() === currentUserId.toString()) {
|
// Phân quyền: Admin hoặc Chủ sở hữu Scene
|
||||||
|
const isAdmin = userRole === 'Chủ sở hữu' || userRole === 'admin';
|
||||||
|
if (isAdmin || (currentUserId && ownerId && ownerId.toString() === currentUserId.toString())) {
|
||||||
handleEditDeleteScene(scene);
|
handleEditDeleteScene(scene);
|
||||||
} else {
|
} else {
|
||||||
alert("Bạn không có quyền chỉnh sửa scene này.");
|
alert("Bạn không có quyền chỉnh sửa scene này.");
|
||||||
@@ -482,9 +498,11 @@ function closeActionModal() {
|
|||||||
*/
|
*/
|
||||||
function openEditSceneModal(scene) {
|
function openEditSceneModal(scene) {
|
||||||
document.getElementById('modal-scene-id').value = scene._id;
|
document.getElementById('modal-scene-id').value = scene._id;
|
||||||
document.getElementById('modal-lat').value = scene.lat;
|
// Cập nhật để hỗ trợ cả cấu trúc cũ và mới (gps.lat/name)
|
||||||
document.getElementById('modal-lng').value = scene.lng;
|
document.getElementById('modal-lat').value = scene.gps?.lat || scene.lat;
|
||||||
document.getElementById('modal-title').value = scene.title;
|
document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng;
|
||||||
|
const sceneName = scene.name || scene.title;
|
||||||
|
document.getElementById('modal-title').value = sceneName;
|
||||||
document.getElementById('modal-privacy').value = scene.privacy;
|
document.getElementById('modal-privacy').value = scene.privacy;
|
||||||
document.getElementById('modal-panorama').required = false; // Photo update is optional
|
document.getElementById('modal-panorama').required = false; // Photo update is optional
|
||||||
|
|
||||||
@@ -537,22 +555,28 @@ async function openScene(sceneId, privacy, shareToken, force = false) {
|
|||||||
localStorage.setItem('activeScenePrivacy', privacy || '');
|
localStorage.setItem('activeScenePrivacy', privacy || '');
|
||||||
localStorage.setItem('activeSceneToken', shareToken || '');
|
localStorage.setItem('activeSceneToken', shareToken || '');
|
||||||
|
|
||||||
const response = await fetch(url, {
|
// Nạp đồng thời Scene và danh sách Hotspots từ Collection riêng
|
||||||
method: 'GET',
|
const [sceneRes, hotspotsRes] = await Promise.all([
|
||||||
headers
|
fetch(url, { method: 'GET', headers }),
|
||||||
});
|
fetch(`${API_BASE_URL}/hotspots/${sceneId}`, { method: 'GET', headers })
|
||||||
|
]);
|
||||||
|
|
||||||
const scene = await response.json();
|
const scene = await sceneRes.json();
|
||||||
if (!response.ok) throw new Error(scene.message || 'Failed to fetch scene details');
|
const hotspots = await hotspotsRes.json();
|
||||||
|
|
||||||
|
if (!sceneRes.ok) throw new Error(scene.message || 'Failed to fetch scene details');
|
||||||
|
|
||||||
|
// Lấy ID người tạo (createdBy) để phân quyền chuột phải trong viewer
|
||||||
|
const sceneOwnerId = scene.createdBy?._id || scene.createdBy || scene.owner?._id || scene.owner;
|
||||||
|
|
||||||
// Tự động focus bản đồ vào vị trí của Scene
|
// Tự động focus bản đồ vào vị trí của Scene
|
||||||
if (map) {
|
if (map) {
|
||||||
map.flyTo([scene.lat, scene.lng], 16);
|
map.flyTo([scene.gps?.lat || scene.lat, scene.gps?.lng || scene.lng], 16);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Cập nhật tọa độ vào các input ẩn để hỗ trợ GPS inheritance cho hotspot khi tải ảnh mới
|
// 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-lat').value = scene.gps?.lat || scene.lat;
|
||||||
document.getElementById('modal-lng').value = scene.lng;
|
document.getElementById('modal-lng').value = scene.gps?.lng || scene.lng;
|
||||||
|
|
||||||
// Cập nhật lịch sử di chuyển để hỗ trợ tạo hotspot ngược tự động
|
// Cập nhật lịch sử di chuyển để hỗ trợ tạo hotspot ngược tự động
|
||||||
if (currentSceneId && currentSceneId !== sceneId) {
|
if (currentSceneId && currentSceneId !== sceneId) {
|
||||||
@@ -560,8 +584,11 @@ async function openScene(sceneId, privacy, shareToken, force = false) {
|
|||||||
}
|
}
|
||||||
currentSceneId = sceneId;
|
currentSceneId = sceneId;
|
||||||
|
|
||||||
// Construct secure image URL passing shareToken if applicable
|
// Kiểm tra an toàn assetId (hỗ trợ cả dạng Object và String ID)
|
||||||
let secureImageUrl = `${API_BASE_URL}/assets/view/${scene.assetId._id}`;
|
const assetId = scene.assetId?._id || scene.assetId;
|
||||||
|
if (!assetId) throw new Error("Dữ liệu hình ảnh của cảnh này bị lỗi hoặc chưa xử lý xong.");
|
||||||
|
|
||||||
|
let secureImageUrl = `${API_BASE_URL}/assets/view/${assetId}`;
|
||||||
|
|
||||||
// Ưu tiên JWT token nếu đang đăng nhập, nếu không thì dùng shareToken
|
// Ưu tiên JWT token nếu đang đăng nhập, nếu không thì dùng shareToken
|
||||||
if (token) {
|
if (token) {
|
||||||
@@ -571,7 +598,7 @@ async function openScene(sceneId, privacy, shareToken, force = false) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Initialize 3D Viewer with secure, referer-protected image stream
|
// Initialize 3D Viewer with secure, referer-protected image stream
|
||||||
initPanoramaViewer(secureImageUrl, scene.hotspots || []);
|
initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
localStorage.removeItem('activeSceneId');
|
localStorage.removeItem('activeSceneId');
|
||||||
@@ -609,14 +636,22 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
|||||||
const modal = document.getElementById('hotspot-modal');
|
const modal = document.getElementById('hotspot-modal');
|
||||||
const form = document.getElementById('hotspot-form');
|
const form = document.getElementById('hotspot-form');
|
||||||
|
|
||||||
|
// Hiển thị Modal TRƯỚC để các logic UI (như Mini Map) tính toán được kích thước
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
// Reset form và gán tọa độ
|
// Reset form và gán tọa độ
|
||||||
form.reset();
|
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-pitch').value = pitch;
|
||||||
document.getElementById('hs-yaw').value = yaw;
|
document.getElementById('hs-yaw').value = yaw;
|
||||||
document.getElementById('hs-id').value = existingHotspot ? existingHotspot._id : '';
|
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';
|
document.getElementById('hotspot-modal-title').innerText = existingHotspot ? 'Cập nhật điểm điều hướng' : 'Thêm điểm điều hướng mới';
|
||||||
|
|
||||||
|
// Reset UI states
|
||||||
|
document.querySelector('input[name="hsLinkType"][value="existing"]').checked = true;
|
||||||
|
window.toggleHSLinkType('existing');
|
||||||
|
document.querySelector('input[name="hsGPSMode"][value="map"]').checked = true;
|
||||||
|
window.toggleHSGPSMode('map');
|
||||||
|
|
||||||
// Lấy danh sách Scene có sẵn để đổ vào dropdown
|
// Lấy danh sách Scene có sẵn để đổ vào dropdown
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/scenes`, { headers: { 'Authorization': `Bearer ${token}` } });
|
const res = await fetch(`${API_BASE_URL}/scenes`, { headers: { 'Authorization': `Bearer ${token}` } });
|
||||||
@@ -625,52 +660,68 @@ window.handleHotspotCreation = async function(pitch, yaw, existingHotspot = null
|
|||||||
select.innerHTML = '<option value="">-- Chọn một cảnh để liên kết --</option>';
|
select.innerHTML = '<option value="">-- Chọn một cảnh để liên kết --</option>';
|
||||||
scenes.forEach(s => {
|
scenes.forEach(s => {
|
||||||
if (s._id !== currentSceneId) { // Không liên kết tới chính nó
|
if (s._id !== currentSceneId) { // Không liên kết tới chính nó
|
||||||
select.innerHTML += `<option value="${s._id}">${s.title}</option>`;
|
select.innerHTML += `<option value="${s._id}">${s.name || s.title}</option>`;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options
|
// QUAN TRỌNG: Chỉ điền dữ liệu hotspot cũ SAU KHI dropdown đã được nạp đầy đủ options
|
||||||
if (existingHotspot) {
|
if (existingHotspot) {
|
||||||
document.getElementById('hs-title').value = existingHotspot.text || '';
|
document.getElementById('hs-title').value = existingHotspot.title || '';
|
||||||
document.getElementById('hs-desc').value = existingHotspot.description || '';
|
document.getElementById('hs-desc').value = existingHotspot.description || '';
|
||||||
if (existingHotspot.targetSceneId) {
|
if (existingHotspot.target_scene_id) {
|
||||||
select.value = existingHotspot.targetSceneId;
|
select.value = existingHotspot.target_scene_id;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (e) { console.error("Lỗi nạp danh sách scene:", e); }
|
} catch (e) { console.error("Lỗi nạp danh sách scene:", e); }
|
||||||
|
|
||||||
modal.style.display = 'flex';
|
|
||||||
|
|
||||||
// Xử lý sự kiện submit form
|
// Xử lý sự kiện submit form
|
||||||
form.onsubmit = async (e) => {
|
form.onsubmit = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const formData = new FormData(form);
|
const formData = new FormData(form);
|
||||||
|
const linkType = formData.get('hsLinkType');
|
||||||
// Logic: Nếu chọn upload file mới, tạo Scene trước
|
|
||||||
let finalTargetId = formData.get('targetSceneId');
|
if (linkType === 'upload') {
|
||||||
const file = document.getElementById('hs-panorama-file').files[0];
|
const file = document.getElementById('hs-panorama-file').files[0];
|
||||||
|
if (!file) {
|
||||||
if (file) {
|
alert('Vui lòng chọn ảnh panorama.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const gpsMode = formData.get('hsGPSMode');
|
||||||
|
let lat = 0, lng = 0;
|
||||||
|
|
||||||
|
if (gpsMode === 'inherit') {
|
||||||
|
// Ép kiểu về Number khi lấy từ input
|
||||||
|
lat = Number(document.getElementById('modal-lat').value);
|
||||||
|
lng = Number(document.getElementById('modal-lng').value);
|
||||||
|
} else {
|
||||||
|
lat = Number(document.getElementById('hs-lat').value);
|
||||||
|
lng = Number(document.getElementById('hs-lng').value);
|
||||||
|
if (!lat || !lng) {
|
||||||
|
alert('Vui lòng chọn vị trí GPS.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const sceneData = new FormData();
|
const sceneData = new FormData();
|
||||||
sceneData.append('panorama', file);
|
sceneData.append('panorama', file);
|
||||||
sceneData.append('title', formData.get('title'));
|
sceneData.append('title', formData.get('title'));
|
||||||
const gpsMode = document.getElementById('hs-gps-mode').value;
|
sceneData.append('lat', lat); // FormData sẽ convert sang string, Backend cần ép kiểu lại
|
||||||
if (gpsMode === 'manual') {
|
sceneData.append('lng', lng);
|
||||||
sceneData.append('lat', document.getElementById('hs-lat').value);
|
sceneData.append('privacy', 'public');
|
||||||
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) => {
|
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);
|
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), sceneRes.scene._id, existingHotspot?._id);
|
||||||
closeHotspotModal();
|
closeHotspotModal();
|
||||||
});
|
});
|
||||||
return; // Dừng luồng cũ vì uploadWithProgress đã tiếp quản
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lưu Hotspot
|
const finalTargetId = formData.get('targetSceneId');
|
||||||
|
if (!finalTargetId) {
|
||||||
|
alert('Vui lòng chọn cảnh để liên kết.');
|
||||||
|
return;
|
||||||
|
}
|
||||||
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id);
|
await saveHotspotToDB(pitch, yaw, formData.get('title'), formData.get('description'), finalTargetId, existingHotspot?._id);
|
||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
};
|
};
|
||||||
@@ -684,25 +735,64 @@ function closeHotspotModal() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Chuyển đổi giữa tab Chọn ảnh có sẵn và Tải ảnh mới
|
* Khởi tạo hoặc cập nhật Mini Map trong Hotspot Modal
|
||||||
|
*/
|
||||||
|
function initHSMiniMap() {
|
||||||
|
const pLat = parseFloat(document.getElementById('modal-lat').value) || 21.0285;
|
||||||
|
const pLng = parseFloat(document.getElementById('modal-lng').value) || 105.8542;
|
||||||
|
|
||||||
|
if (miniMap) {
|
||||||
|
miniMap.setView([pLat, pLng], 15);
|
||||||
|
updateHSMiniMapMarker(pLat, pLng);
|
||||||
|
// Fix lỗi vỡ tiles của Leaflet trong Modal
|
||||||
|
setTimeout(() => miniMap.invalidateSize(), 200);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
miniMap = L.map('hs-mini-map').setView([pLat, pLng], 15);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(miniMap);
|
||||||
|
|
||||||
|
miniMap.on('click', (e) => {
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
updateHSMiniMapMarker(lat, lng);
|
||||||
|
});
|
||||||
|
|
||||||
|
updateHSMiniMapMarker(pLat, pLng);
|
||||||
|
setTimeout(() => miniMap.invalidateSize(), 200);
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHSMiniMapMarker(lat, lng) {
|
||||||
|
if (miniMapMarker) miniMap.removeLayer(miniMapMarker);
|
||||||
|
miniMapMarker = L.marker([lat, lng]).addTo(miniMap);
|
||||||
|
document.getElementById('hs-lat').value = lat.toFixed(6);
|
||||||
|
document.getElementById('hs-lng').value = lng.toFixed(6);
|
||||||
|
}
|
||||||
|
|
||||||
|
window.toggleHSLinkType = function(type) {
|
||||||
|
document.getElementById('hs-section-existing').style.display = type === 'existing' ? 'block' : 'none';
|
||||||
|
document.getElementById('hs-section-upload').style.display = type === 'upload' ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (type === 'upload') {
|
||||||
|
const gpsMode = document.querySelector('input[name="hsGPSMode"]:checked')?.value || 'map';
|
||||||
|
window.toggleHSGPSMode(gpsMode);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.toggleHSGPSMode = function(mode) {
|
||||||
|
document.getElementById('hs-map-selector').style.display = mode === 'map' ? 'block' : 'none';
|
||||||
|
document.getElementById('hs-manual-gps').style.display = mode === 'manual' ? 'block' : 'none';
|
||||||
|
|
||||||
|
if (mode === 'map') initHSMiniMap();
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chuyển đổi tab cũ (giữ lại để tương thích nếu cần)
|
||||||
*/
|
*/
|
||||||
function switchHSTab(tabName) {
|
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') {
|
if (tabName === 'select') {
|
||||||
selectTab.style.display = 'block';
|
window.toggleHSLinkType('existing');
|
||||||
uploadTab.style.display = 'none';
|
|
||||||
btns[0].classList.add('active');
|
|
||||||
document.getElementById('hs-panorama-file').value = ''; // Reset file input
|
|
||||||
} else {
|
} else {
|
||||||
selectTab.style.display = 'none';
|
window.toggleHSLinkType('upload');
|
||||||
uploadTab.style.display = 'block';
|
|
||||||
btns[1].classList.add('active');
|
|
||||||
document.getElementById('hs-target-id').value = ''; // Reset select
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,40 +808,43 @@ function toggleManualGPS() {
|
|||||||
/**
|
/**
|
||||||
* Gửi dữ liệu lưu Hotspot lên Backend
|
* Gửi dữ liệu lưu Hotspot lên Backend
|
||||||
*/
|
*/
|
||||||
async function saveHotspotToDB(pitch, yaw, text, description, targetSceneId, hotspotId) {
|
async function saveHotspotToDB(pitch, yaw, title, description, targetSceneId, hotspotId) {
|
||||||
const token = localStorage.getItem('jwt');
|
const token = localStorage.getItem('jwt');
|
||||||
|
// Gọi đúng API create hoặc update tùy vào trạng thái
|
||||||
|
const url = hotspotId ? `${API_BASE_URL}/hotspots/update/${hotspotId}` : `${API_BASE_URL}/hotspots/create`;
|
||||||
|
const method = hotspotId ? 'PUT' : 'POST';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const response = await fetch(`${API_BASE_URL}/scenes/${currentSceneId}/hotspots`, {
|
const body = {
|
||||||
method: 'POST',
|
title,
|
||||||
|
description,
|
||||||
|
target_scene_id: targetSceneId,
|
||||||
|
coordinates: {
|
||||||
|
pitch: Number(pitch),
|
||||||
|
yaw: Number(yaw)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// Nếu tạo mới, cần gửi kèm ID của scene hiện tại làm parent
|
||||||
|
if (!hotspotId) {
|
||||||
|
body.parent_scene_id = currentSceneId;
|
||||||
|
}
|
||||||
|
|
||||||
|
const response = await fetch(url, {
|
||||||
|
method: method,
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': `Bearer ${token}`
|
'Authorization': `Bearer ${token}`
|
||||||
},
|
},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(body)
|
||||||
hotspotId,
|
|
||||||
pitch,
|
|
||||||
yaw,
|
|
||||||
text,
|
|
||||||
description,
|
|
||||||
targetSceneId
|
|
||||||
})
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
if (!response.ok) throw new Error(data.message || 'Lỗi khi lưu hotspot');
|
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!');
|
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
|
// Buộc nạp lại để cập nhật danh sách hotspot mới
|
||||||
openScene(currentSceneId, updatedScene.privacy, updatedScene.shareToken || '', true);
|
openScene(currentSceneId, localStorage.getItem('activeScenePrivacy'), localStorage.getItem('activeSceneToken'), true);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -785,3 +878,60 @@ window.systemReset = async function() {
|
|||||||
alert("Lỗi reset: " + e.message);
|
alert("Lỗi reset: " + e.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mở Menu tùy chọn cho Hotspot (Sửa/Xóa)
|
||||||
|
*/
|
||||||
|
window.openHotspotMenu = function(hotspot) {
|
||||||
|
const modal = document.getElementById('hotspot-action-modal');
|
||||||
|
const editBtn = document.getElementById('btn-hs-edit');
|
||||||
|
const deleteBtn = document.getElementById('btn-hs-delete');
|
||||||
|
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
// Hành động Sửa: Mở form biên tập với dữ liệu cũ
|
||||||
|
editBtn.onclick = () => {
|
||||||
|
closeHotspotActionModal();
|
||||||
|
window.handleHotspotCreation(hotspot.pitch, hotspot.yaw, hotspot);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Hành động Xóa: Xác nhận và gọi API xóa
|
||||||
|
deleteBtn.onclick = async () => {
|
||||||
|
if (confirm('Bạn có chắc chắn muốn xóa điểm điều hướng này?')) {
|
||||||
|
closeHotspotActionModal();
|
||||||
|
await deleteHotspot(hotspot._id);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Đóng Modal tùy chọn Hotspot
|
||||||
|
*/
|
||||||
|
function closeHotspotActionModal() {
|
||||||
|
document.getElementById('hotspot-action-modal').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xóa Hotspot thông qua API
|
||||||
|
*/
|
||||||
|
async function deleteHotspot(hotspotId) {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
try {
|
||||||
|
// Gọi đúng API delete hotspot độc lập
|
||||||
|
const response = await fetch(`${API_BASE_URL}/hotspots/delete/${hotspotId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const err = await response.json();
|
||||||
|
throw new Error(err.message || 'Lỗi xóa hotspot');
|
||||||
|
}
|
||||||
|
|
||||||
|
alert('Đã xóa điểm điều hướng.');
|
||||||
|
// Refresh lại scene hiện tại để cập nhật viewer
|
||||||
|
openScene(currentSceneId, null, null, true);
|
||||||
|
} catch (e) {
|
||||||
|
alert(e.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
+34
-11
@@ -1,14 +1,17 @@
|
|||||||
let activeViewer = null;
|
let activeViewer = null;
|
||||||
let currentHotspots = [];
|
let currentHotspots = [];
|
||||||
let securityApplied = false;
|
let securityApplied = false;
|
||||||
|
let currentSceneOwnerId = null;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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
|
* @param {Array} hotspots - List of hotspots from the database
|
||||||
|
* @param {string} ownerId - ID of the scene owner
|
||||||
*/
|
*/
|
||||||
function initPanoramaViewer(imageUrl, hotspots = []) {
|
function initPanoramaViewer(imageUrl, hotspots = [], ownerId = null) {
|
||||||
currentHotspots = hotspots;
|
currentHotspots = hotspots;
|
||||||
|
currentSceneOwnerId = ownerId;
|
||||||
const container = document.getElementById('viewer-container');
|
const container = document.getElementById('viewer-container');
|
||||||
container.style.display = 'block';
|
container.style.display = 'block';
|
||||||
|
|
||||||
@@ -20,15 +23,15 @@ function initPanoramaViewer(imageUrl, hotspots = []) {
|
|||||||
|
|
||||||
// Chuyển đổi dữ liệu hotspots từ DB sang định dạng Pannellum
|
// Chuyển đổi dữ liệu hotspots từ DB sang định dạng Pannellum
|
||||||
const pannellumHotspots = hotspots.map(h => ({
|
const pannellumHotspots = hotspots.map(h => ({
|
||||||
pitch: h.pitch,
|
pitch: h.coordinates?.pitch || h.pitch,
|
||||||
yaw: h.yaw,
|
yaw: h.coordinates?.yaw || h.yaw,
|
||||||
type: "info",
|
type: "info",
|
||||||
text: h.text || "Điểm điều hướng",
|
text: h.title || "Điểm điều hướng",
|
||||||
id: h._id,
|
id: h._id,
|
||||||
clickHandlerFunc: () => {
|
clickHandlerFunc: () => {
|
||||||
if (h.targetSceneId) {
|
if (h.target_scene_id || h.targetSceneId) {
|
||||||
// Gọi hàm openScene từ main_map.js
|
// Gọi hàm openScene từ main_map.js
|
||||||
openScene(h.targetSceneId);
|
openScene(h.target_scene_id || h.targetSceneId);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
@@ -87,23 +90,43 @@ function applyViewerSecurity() {
|
|||||||
|
|
||||||
// Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click
|
// Nếu viewer đang hoạt động, lấy tọa độ Pitch/Yaw tại điểm click
|
||||||
if (activeViewer) {
|
if (activeViewer) {
|
||||||
|
// Kiểm tra phân quyền trước khi cho phép tương tác chuột phải
|
||||||
|
const userRole = localStorage.getItem('role');
|
||||||
|
const currentUserId = localStorage.getItem('userId');
|
||||||
|
|
||||||
|
// Phân quyền: Admin (Chủ sở hữu) hoặc Người tạo ra Scene này
|
||||||
|
const isAdmin = userRole === 'Chủ sở hữu' || userRole === 'admin';
|
||||||
|
const isAuthorized = isAdmin ||
|
||||||
|
(currentUserId && currentSceneOwnerId && currentUserId.toString() === currentSceneOwnerId.toString());
|
||||||
|
|
||||||
// Lấy tọa độ cầu (Pitch/Yaw) từ điểm click chuột
|
// Lấy tọa độ cầu (Pitch/Yaw) từ điểm click chuột
|
||||||
const coords = activeViewer.mouseEventToCoords(e);
|
const coords = activeViewer.mouseEventToCoords(e);
|
||||||
if (!coords) return;
|
if (!coords) return false;
|
||||||
|
|
||||||
const pitch = coords[0];
|
const pitch = coords[0];
|
||||||
const yaw = coords[1];
|
const yaw = coords[1];
|
||||||
|
|
||||||
// Kiểm tra xem có hotspot nào gần điểm click không (ngưỡng 2 độ)
|
// Kiểm tra xem có hotspot nào gần điểm click không (ngưỡng 2 độ)
|
||||||
const existing = currentHotspots.find(h =>
|
const existing = currentHotspots.find(h =>
|
||||||
Math.abs(h.pitch - pitch) < 2 && Math.abs(h.yaw - yaw) < 2
|
Math.abs((h.coordinates?.pitch || h.pitch) - pitch) < 2 &&
|
||||||
|
Math.abs((h.coordinates?.yaw || h.yaw) - yaw) < 2
|
||||||
);
|
);
|
||||||
|
|
||||||
if (typeof window.handleHotspotCreation === 'function') {
|
// Nếu không được phép, dừng xử lý và chặn menu mặc định
|
||||||
window.handleHotspotCreation(pitch, yaw, existing);
|
if (!isAuthorized) return false;
|
||||||
|
|
||||||
|
if (existing) {
|
||||||
|
// ĐÃ CÓ Hotspot -> Hiện Menu: [Sửa Hotspot] / [Xóa Hotspot]
|
||||||
|
if (typeof window.openHotspotMenu === 'function') {
|
||||||
|
window.openHotspotMenu(existing);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
console.log(`Coordinates captured: Pitch ${pitch}, Yaw ${yaw}`);
|
// CHƯA CÓ Hotspot -> Hiện Form: [Tạo mới Hotspot]
|
||||||
|
if (typeof window.handleHotspotCreation === 'function') {
|
||||||
|
window.handleHotspotCreation(pitch, yaw, null);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
console.log(`Coordinates captured: Pitch ${pitch}, Yaw ${yaw}`);
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user