diff --git a/backend/models/Scene.js b/backend/models/Scene.js index 361ba6b..3d8cf85 100644 --- a/backend/models/Scene.js +++ b/backend/models/Scene.js @@ -9,6 +9,10 @@ const sceneSchema = new mongoose.Schema({ scene_url: { type: String }, + description: { + type: String, + trim: true + }, assetId: { type: mongoose.Schema.Types.ObjectId, ref: 'Asset', @@ -33,10 +37,17 @@ const sceneSchema = new mongoose.Schema({ unique: true, sparse: true }, + shareTokenExpires: { + type: Date + }, sharedWith: [{ type: mongoose.Schema.Types.ObjectId, ref: 'User' }], + sharedEmails: [{ + type: String, + trim: true + }], }, { timestamps: true }); diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index c5d80f4..fdb29c8 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -165,6 +165,33 @@ router.post('/scenes', protect, uploadSinglePanorama, async (req, res) => { } }); +/** + * @route GET /api/users/search + * @desc Tìm kiếm người dùng theo username hoặc email để chia sẻ + * @access Private + */ +router.get('/users/search', protect, async (req, res) => { + const query = req.query.q; + if (!query || query.length < 2) return res.json([]); + + try { + const users = await User.find({ + $and: [ + { _id: { $ne: req.user._id } }, // Không tìm chính mình + { + $or: [ + { username: { $regex: query, $options: 'i' } }, + { email: { $regex: query, $options: 'i' } } + ] + } + ] + }).select('username email').limit(10); + res.json(users); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + /** * @route GET /api/scenes * @desc Get all accessible scenes for the map (respecting privacy rules) @@ -183,7 +210,8 @@ router.get('/scenes', optionalAuth, async (req, res) => { { privacy: 'member' }, { privacy: 'shared' }, // shareToken will be required to fetch panorama, but coordinates show on map { createdBy: req.user._id }, - { sharedWith: req.user._id } + { sharedWith: req.user._id }, + { sharedEmails: req.user.email } ] }; } else { @@ -223,12 +251,17 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => { return res.status(404).json({ message: 'Scene not found' }); } + // Kiểm tra Token hết hạn + const isTokenValid = scene.shareToken && + (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + + const userEmail = req.user ? req.user.email : null; const hasAccess = scene.privacy === 'public' || - (scene.privacy === 'member' && req.user) || + (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || (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); + (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); if (!hasAccess) { return res.status(403).json({ message: 'Access denied to this scene' }); @@ -382,12 +415,17 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res return res.status(403).json({ message: 'Access denied' }); } } else { + // Kiểm tra Token hết hạn + const isTokenValid = scene.shareToken && + (!scene.shareTokenExpires || new Date() < scene.shareTokenExpires); + + const userEmail = req.user ? req.user.email : null; const hasAccess = scene.privacy === 'public' || - (scene.privacy === 'member' && req.user) || + (scene.privacy === 'member' && req.user && (scene.sharedWith.includes(req.user._id) || (userEmail && scene.sharedEmails.includes(userEmail)))) || (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); + (scene.privacy === 'shared' && req.query.token === scene.shareToken && isTokenValid); if (!hasAccess) { return res.status(403).json({ message: 'Access denied: You do not have permission to view this asset' }); @@ -423,7 +461,7 @@ router.get('/assets/view/:assetId', verifyReferer, optionalAuth, async (req, res */ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { try { - const { title, privacy, sharedWithUsers, lat, lng } = req.body; + const { title, description, privacy, sharedWithUsers, sharedEmails, shareExpireDays, lat, lng } = req.body; const scene = await Scene.findById(req.params.id); if (!scene || scene.createdBy.toString() !== req.user._id.toString()) { @@ -434,10 +472,27 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { // Update basic info scene.name = title || scene.name; + scene.description = description !== undefined ? description : scene.description; scene.privacy = privacy || scene.privacy; if (lat) scene.gps.lat = parseFloat(lat); if (lng) scene.gps.lng = parseFloat(lng); + // Cập nhật danh sách chia sẻ + if (sharedWithUsers) { + try { + scene.sharedWith = JSON.parse(sharedWithUsers); + } catch (e) { + console.error("Lỗi parse sharedWithUsers:", e); + } + } + if (sharedEmails) { + try { + scene.sharedEmails = JSON.parse(sharedEmails); + } catch (e) { + console.error("Lỗi parse sharedEmails:", e); + } + } + // LOGIC ĐỒNG BỘ QUYỀN RIÊNG TƯ (CASCADING PRIVACY) // Nếu quyền chia sẻ thay đổi, cập nhật toàn bộ các Scene con liên kết trực tiếp if (privacy && privacy !== oldPrivacy) { @@ -467,8 +522,18 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => { } } - if (privacy === 'shared' && !scene.shareToken) { - scene.shareToken = crypto.randomBytes(24).toString('hex'); + if (privacy === 'shared') { + if (!scene.shareToken) { + scene.shareToken = crypto.randomBytes(24).toString('hex'); + } + // Thiết lập ngày hết hạn nếu có truyền lên + if (shareExpireDays && shareExpireDays !== 'never') { + const expires = new Date(); + expires.setDate(expires.getDate() + parseInt(shareExpireDays)); + scene.shareTokenExpires = expires; + } else if (shareExpireDays === 'never') { + scene.shareTokenExpires = null; + } } // Update image if new one is uploaded diff --git a/frontend/css/style.css b/frontend/css/style.css index c1f86f5..f9c4e64 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -597,9 +597,11 @@ html, body { #logout-confirm-modal, #delete-asset-confirm-modal, #success-modal { - z-index: 5500; /* Cao hơn Dashboard (4500) và Close Button (5000) */ + z-index: 5500; } +#share-member-modal, #share-link-modal { z-index: 6000; } + /* --- Pannellum Custom Hotspot (Callout Bubble) --- */ /* Container chính của hotspot do Pannellum quản lý */ @@ -752,3 +754,99 @@ html, body { .edit-btn-small { background: #28a745; color: white; } .delete-btn-small { background: #dc3545; color: white; } .media-actions button:hover { opacity: 0.8; } + +/* --- Edit Metadata Modal (Dark Theme) --- */ +#edit-scene-metadata-modal .modal-content { + background: rgba(30, 30, 30, 0.95); + color: #fff; + border: 1px solid rgba(255, 255, 255, 0.1); + backdrop-filter: blur(10px); + max-width: 500px; +} + +#edit-scene-metadata-modal .form-group label { + color: #ccc; +} + +#edit-scene-metadata-modal input, +#edit-scene-metadata-modal textarea, +#edit-scene-metadata-modal select { + background: rgba(255, 255, 255, 0.05); + border: 1px solid rgba(255, 255, 255, 0.2); + color: #fff; +} + +#edit-mini-map { + height: 200px; + width: 100%; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.1); +} + +/* --- Privacy Settings Enhancements --- */ +.privacy-settings-btn { + background: rgba(255, 255, 255, 0.1); + border: 1px solid rgba(255, 255, 255, 0.2); + color: white; + padding: 8px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 18px; + transition: all 0.2s; +} + +.privacy-settings-btn:hover { + background: rgba(0, 123, 255, 0.4); + border-color: #007bff; +} + +/* Search Dropdown */ +.search-dropdown { + position: absolute; + top: 100%; left: 0; width: 100%; + background: #2a2a2a; + border: 1px solid #444; + border-top: none; + max-height: 200px; + overflow-y: auto; + z-index: 100; + border-radius: 0 0 4px 4px; + box-shadow: 0 4px 12px rgba(0,0,0,0.5); +} + +.search-item { + padding: 10px 15px; + cursor: pointer; + border-bottom: 1px solid #333; + font-size: 14px; +} + +.search-item:hover { background: #3a3a3a; color: #00d4ff; } + +/* Shared Users List */ +.shared-users-list { + margin-top: 10px; + max-height: 150px; + overflow-y: auto; + background: rgba(0,0,0,0.2); + border-radius: 4px; + padding: 5px; +} + +.share-list-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 12px; + background: rgba(255,255,255,0.05); + margin-bottom: 4px; + border-radius: 3px; + font-size: 13px; +} + +.remove-share-btn { + color: #ff4d4d; + cursor: pointer; + font-weight: bold; + padding: 0 5px; +} diff --git a/frontend/index.html b/frontend/index.html index 4163265..4c0418a 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -182,6 +182,111 @@ + +