diff --git a/backend/models/Hotspot.js b/backend/models/Hotspot.js new file mode 100644 index 0000000..1e4a43b --- /dev/null +++ b/backend/models/Hotspot.js @@ -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); \ No newline at end of file diff --git a/backend/models/Scene.js b/backend/models/Scene.js index b88d2e0..361ba6b 100644 --- a/backend/models/Scene.js +++ b/backend/models/Scene.js @@ -1,25 +1,24 @@ const mongoose = require('mongoose'); const sceneSchema = new mongoose.Schema({ - title: { + name: { type: String, required: true, trim: true }, + scene_url: { + type: String + }, assetId: { type: mongoose.Schema.Types.ObjectId, ref: 'Asset', required: true }, - lat: { - type: Number, - required: true + gps: { + lat: { type: Number, required: true }, + lng: { type: Number, required: true } }, - lng: { - type: Number, - required: true - }, - owner: { + createdBy: { type: mongoose.Schema.Types.ObjectId, ref: 'User', required: true @@ -38,28 +37,6 @@ const sceneSchema = new mongoose.Schema({ type: mongoose.Schema.Types.ObjectId, 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 }); diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index 51ddf1d..9596c69 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -7,6 +7,7 @@ const crypto = require('crypto'); const User = require('../models/User'); const Asset = require('../models/Asset'); 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 { 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' }); } - const latitude = parseFloat(lat); - const longitude = parseFloat(lng); + const latitude = Number(lat); + const longitude = Number(lng); if (isNaN(latitude) || isNaN(longitude)) { // Cleanup uploaded file on validation error @@ -134,11 +135,14 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => { // 7. Save Scene to DB const scene = new Scene({ - title, + name: title, assetId: asset._id, - lat: latitude, - lng: longitude, - owner: req.user._id, + scene_url: processedFilePath, // Lưu đường dẫn ảnh trực tiếp + gps: { + lat: latitude, + lng: longitude + }, + createdBy: req.user._id, privacy: privacy || 'private', shareToken, sharedWith: parsedSharedWith @@ -176,7 +180,7 @@ router.get('/scenes', optionalAuth, async (req, res) => { { privacy: 'public' }, { privacy: 'member' }, { 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 } ] }; @@ -191,8 +195,8 @@ router.get('/scenes', optionalAuth, async (req, res) => { } const scenes = await Scene.find(query) - .populate('owner', 'username') - .populate('assetId', 'coordinates createdAt'); + .populate('createdBy', 'username') + .lean(); console.log(`[Data Load] Đã tìm thấy ${scenes.length} scenes. Gửi phản hồi về Frontend...`); res.json(scenes); @@ -210,7 +214,7 @@ router.get('/scenes', optionalAuth, async (req, res) => { router.get('/scenes/:id', optionalAuth, async (req, res) => { try { const scene = await Scene.findById(req.params.id) - .populate('owner', 'username') + .populate('createdBy', 'username') .populate('assetId'); if (!scene) { @@ -220,7 +224,7 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => { const hasAccess = scene.privacy === 'public' || (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)) || (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 - * @desc Add a new hotspot to a scene - * @access Private (Owner only) + * @route GET /api/hotspots/:scene_id + * @desc Lấy toàn bộ danh sách hotspot của scene hiện tại */ -router.post('/scenes/:id/hotspots', protect, async (req, res) => { +router.get('/hotspots/:scene_id', async (req, res) => { try { - const { hotspotId, pitch, yaw, text, description, targetSceneId } = req.body; - const scene = await Scene.findById(req.params.id); + const hotspots = await Hotspot.find({ parent_scene_id: req.params.scene_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 - 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(); - - // 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 + const hotspot = new Hotspot({ + parent_scene_id, + target_scene_id, + title, + description, + coordinates: { + yaw: Number(coordinates.yaw), + pitch: Number(coordinates.pitch) + }, + is_auto_return: false }); + 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) { res.status(500).json({ message: error.message }); } @@ -340,7 +391,7 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res const hasAccess = scene.privacy === 'public' || (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)) || (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 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' }); } // Update basic info - scene.title = title || scene.title; + scene.name = title || scene.name; scene.privacy = privacy || scene.privacy; - scene.lat = lat ? parseFloat(lat) : scene.lat; - scene.lng = lng ? parseFloat(lng) : scene.lng; + if (lat) scene.gps.lat = Number(lat); + if (lng) scene.gps.lng = Number(lng); if (privacy === 'shared' && !scene.shareToken) { 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) => { try { 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' }); } diff --git a/backend/uploads/processed_1780890413280_888fcfc5.JPG.jpg b/backend/uploads/processed_1780890413280_888fcfc5.JPG.jpg deleted file mode 100644 index 537a6c8..0000000 Binary files a/backend/uploads/processed_1780890413280_888fcfc5.JPG.jpg and /dev/null differ diff --git a/backend/uploads/processed_1780890373010_44576a2c.JPG.jpg b/backend/uploads/processed_1780894157770_b06d7c6e.JPG.jpg similarity index 99% rename from backend/uploads/processed_1780890373010_44576a2c.JPG.jpg rename to backend/uploads/processed_1780894157770_b06d7c6e.JPG.jpg index c503d59..552e6da 100644 Binary files a/backend/uploads/processed_1780890373010_44576a2c.JPG.jpg and b/backend/uploads/processed_1780894157770_b06d7c6e.JPG.jpg differ diff --git a/backend/uploads/processed_1780894179644_5d0cb801.JPG.jpg b/backend/uploads/processed_1780894179644_5d0cb801.JPG.jpg new file mode 100644 index 0000000..fdb1a86 Binary files /dev/null and b/backend/uploads/processed_1780894179644_5d0cb801.JPG.jpg differ diff --git a/frontend/css/style.css b/frontend/css/style.css index a9417f0..0495a73 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -74,7 +74,7 @@ html, body { width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); - z-index: 2000; + z-index: 4000; justify-content: center; align-items: center; } @@ -170,6 +170,16 @@ html, body { #close-viewer-btn:hover { 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 { background: #fff; padding: 20px; diff --git a/frontend/index.html b/frontend/index.html index eabaae5..dfb08c7 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -104,6 +104,22 @@ + + +
- -
- - + +
+ +
- -
+ +
- -