Sửa chế độ chia sẻ privacy trực tiếp khi nhấn chuột phải lên scene
This commit is contained in:
@@ -9,6 +9,10 @@ const sceneSchema = new mongoose.Schema({
|
|||||||
scene_url: {
|
scene_url: {
|
||||||
type: String
|
type: String
|
||||||
},
|
},
|
||||||
|
description: {
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
},
|
||||||
assetId: {
|
assetId: {
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'Asset',
|
ref: 'Asset',
|
||||||
@@ -33,10 +37,17 @@ const sceneSchema = new mongoose.Schema({
|
|||||||
unique: true,
|
unique: true,
|
||||||
sparse: true
|
sparse: true
|
||||||
},
|
},
|
||||||
|
shareTokenExpires: {
|
||||||
|
type: Date
|
||||||
|
},
|
||||||
sharedWith: [{
|
sharedWith: [{
|
||||||
type: mongoose.Schema.Types.ObjectId,
|
type: mongoose.Schema.Types.ObjectId,
|
||||||
ref: 'User'
|
ref: 'User'
|
||||||
}],
|
}],
|
||||||
|
sharedEmails: [{
|
||||||
|
type: String,
|
||||||
|
trim: true
|
||||||
|
}],
|
||||||
}, {
|
}, {
|
||||||
timestamps: true
|
timestamps: true
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
|
* @route GET /api/scenes
|
||||||
* @desc Get all accessible scenes for the map (respecting privacy rules)
|
* @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: '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
|
||||||
{ createdBy: req.user._id },
|
{ createdBy: req.user._id },
|
||||||
{ sharedWith: req.user._id }
|
{ sharedWith: req.user._id },
|
||||||
|
{ sharedEmails: req.user.email }
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
@@ -223,12 +251,17 @@ router.get('/scenes/:id', optionalAuth, async (req, res) => {
|
|||||||
return res.status(404).json({ message: 'Scene not found' });
|
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 =
|
const hasAccess =
|
||||||
scene.privacy === 'public' ||
|
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.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 && isTokenValid);
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return res.status(403).json({ message: 'Access denied to this scene' });
|
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' });
|
return res.status(403).json({ message: 'Access denied' });
|
||||||
}
|
}
|
||||||
} else {
|
} 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 =
|
const hasAccess =
|
||||||
scene.privacy === 'public' ||
|
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.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 && isTokenValid);
|
||||||
|
|
||||||
if (!hasAccess) {
|
if (!hasAccess) {
|
||||||
return res.status(403).json({ message: 'Access denied: You do not have permission to view this asset' });
|
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) => {
|
router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
|
||||||
try {
|
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);
|
const scene = await Scene.findById(req.params.id);
|
||||||
|
|
||||||
if (!scene || scene.createdBy.toString() !== req.user._id.toString()) {
|
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
|
// Update basic info
|
||||||
scene.name = title || scene.name;
|
scene.name = title || scene.name;
|
||||||
|
scene.description = description !== undefined ? description : scene.description;
|
||||||
scene.privacy = privacy || scene.privacy;
|
scene.privacy = privacy || scene.privacy;
|
||||||
if (lat) scene.gps.lat = parseFloat(lat);
|
if (lat) scene.gps.lat = parseFloat(lat);
|
||||||
if (lng) scene.gps.lng = parseFloat(lng);
|
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)
|
// 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
|
// 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) {
|
if (privacy && privacy !== oldPrivacy) {
|
||||||
@@ -467,9 +522,19 @@ router.put('/scenes/:id', protect, uploadSinglePanorama, async (req, res) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (privacy === 'shared' && !scene.shareToken) {
|
if (privacy === 'shared') {
|
||||||
|
if (!scene.shareToken) {
|
||||||
scene.shareToken = crypto.randomBytes(24).toString('hex');
|
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
|
// Update image if new one is uploaded
|
||||||
if (req.file) {
|
if (req.file) {
|
||||||
|
|||||||
+99
-1
@@ -597,9 +597,11 @@ html, body {
|
|||||||
#logout-confirm-modal,
|
#logout-confirm-modal,
|
||||||
#delete-asset-confirm-modal,
|
#delete-asset-confirm-modal,
|
||||||
#success-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) --- */
|
/* --- Pannellum Custom Hotspot (Callout Bubble) --- */
|
||||||
|
|
||||||
/* Container chính của hotspot do Pannellum quản lý */
|
/* Container chính của hotspot do Pannellum quản lý */
|
||||||
@@ -752,3 +754,99 @@ html, body {
|
|||||||
.edit-btn-small { background: #28a745; color: white; }
|
.edit-btn-small { background: #28a745; color: white; }
|
||||||
.delete-btn-small { background: #dc3545; color: white; }
|
.delete-btn-small { background: #dc3545; color: white; }
|
||||||
.media-actions button:hover { opacity: 0.8; }
|
.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;
|
||||||
|
}
|
||||||
|
|||||||
+109
-1
@@ -182,6 +182,111 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal for Editing Scene Metadata (No Upload) -->
|
||||||
|
<div id="edit-scene-metadata-modal" class="modal">
|
||||||
|
<div class="modal-content">
|
||||||
|
<span class="close-btn" onclick="closeEditMetadataModal()">×</span>
|
||||||
|
<h2 id="edit-metadata-modal-title">Sửa 3D Scene</h2>
|
||||||
|
<form id="edit-scene-metadata-form" onsubmit="submitEditScene(event)">
|
||||||
|
<input type="hidden" id="edit-modal-scene-id">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-modal-title">Tên Scene:</label>
|
||||||
|
<input type="text" id="edit-modal-title" required placeholder="Tên cảnh quay">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="edit-modal-description">Mô tả:</label>
|
||||||
|
<textarea id="edit-modal-description" rows="3" placeholder="Mô tả chi tiết về cảnh này..."></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Vị trí tọa độ (Click bản đồ để đổi):</label>
|
||||||
|
<div style="display: flex; gap: 10px; margin-bottom: 10px;">
|
||||||
|
<input type="text" id="edit-modal-lat" readonly>
|
||||||
|
<input type="text" id="edit-modal-lng" readonly>
|
||||||
|
</div>
|
||||||
|
<div id="edit-mini-map" style="height: 200px; width: 100%; border-radius: 4px; border: 1px solid #ccc; background: #eee;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group" id="edit-privacy-container">
|
||||||
|
<label for="edit-modal-privacy">Quyền riêng tư:</label>
|
||||||
|
<div style="display: flex; gap: 8px; align-items: center;">
|
||||||
|
<select id="edit-modal-privacy" onchange="handleEditPrivacyChange()" style="flex: 1;">
|
||||||
|
<option value="public">Public (Everyone)</option>
|
||||||
|
<option value="private">Private (Only Me)</option>
|
||||||
|
<option value="member">Members (Specific People)</option>
|
||||||
|
<option value="shared">Shared via Link</option>
|
||||||
|
</select>
|
||||||
|
<button type="button" id="btn-edit-privacy-settings" class="privacy-settings-btn" title="Cài đặt chia sẻ" onclick="openPrivacySettingsModal()" style="display: none;">⚙️</button>
|
||||||
|
</div>
|
||||||
|
<div id="edit-child-privacy-info" style="display: none; color: #888; font-size: 12px; margin-top: 5px;">
|
||||||
|
ℹ️ Cảnh con kế thừa quyền riêng tư từ cảnh cha.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer" style="padding: 0; border: none; background: transparent;">
|
||||||
|
<button type="button" class="cancel-btn" onclick="closeEditMetadataModal()">Hủy bỏ</button>
|
||||||
|
<button type="submit" class="save-btn">Lưu thay đổi</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Manage Shared Members -->
|
||||||
|
<div id="share-member-modal" class="modal-overlay">
|
||||||
|
<div class="modal-content logout-modal-dark" style="max-width: 500px;">
|
||||||
|
<h2 style="color: #fff; margin-bottom: 15px;">Chia sẻ với thành viên</h2>
|
||||||
|
<div class="form-group">
|
||||||
|
<label>Thêm thành viên (Username hoặc Email):</label>
|
||||||
|
<div style="position: relative;">
|
||||||
|
<input type="text" id="share-user-search" placeholder="Nhập tên hoặc email..." oninput="searchUsersToShare(this.value)">
|
||||||
|
<div id="search-results-dropdown" class="search-dropdown" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="shared-list-container">
|
||||||
|
<label>Danh sách đã chia sẻ:</label>
|
||||||
|
<div id="current-shared-list" class="shared-users-list">
|
||||||
|
<!-- Sẽ được fill bằng JS -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" style="padding: 20px 0 0 0; background: transparent; border: none;">
|
||||||
|
<button onclick="closeShareMemberModal()" class="save-btn">Xong</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Modal Shared Link -->
|
||||||
|
<div id="share-link-modal" class="modal-overlay">
|
||||||
|
<div class="modal-content logout-modal-dark" style="max-width: 500px; text-align: center;">
|
||||||
|
<h2 style="color: #fff; margin-bottom: 15px;">Liên kết chia sẻ</h2>
|
||||||
|
<p style="color: #ccc; font-size: 14px; margin-bottom: 20px;">Bất kỳ ai có liên kết này đều có thể xem cảnh quay mà không cần đăng nhập.</p>
|
||||||
|
|
||||||
|
<div class="form-group" style="text-align: left; margin-bottom: 20px;">
|
||||||
|
<label style="color: #fff;">Thời hạn liên kết:</label>
|
||||||
|
<select id="share-link-expire" style="background: #222; color: #fff; border: 1px solid #444;">
|
||||||
|
<option value="7">7 ngày (Mặc định)</option>
|
||||||
|
<option value="1">1 ngày</option>
|
||||||
|
<option value="30">30 ngày</option>
|
||||||
|
<option value="never">Vĩnh viễn</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="link-display-area">
|
||||||
|
<input type="text" id="shared-link-input" readonly style="background: rgba(0,0,0,0.3); color: #00d4ff; border: 1px solid #444; padding: 12px; width: 100%; border-radius: 4px; font-family: monospace; font-size: 13px;">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="action-buttons" style="margin-top: 25px;">
|
||||||
|
<button onclick="copySharedLink()" class="edit-btn-large" style="background: #007bff; width: 100%;">
|
||||||
|
📋 Sao chép liên kết & Đóng
|
||||||
|
</button>
|
||||||
|
<button onclick="closeShareLinkModal()" class="edit-btn-large" style="background: #444; width: 100%; margin-top: 10px;">
|
||||||
|
Đóng
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Delete Asset Confirmation Modal -->
|
<!-- Delete Asset Confirmation Modal -->
|
||||||
<div id="delete-asset-confirm-modal" class="modal-overlay">
|
<div id="delete-asset-confirm-modal" class="modal-overlay">
|
||||||
<div class="modal-content action-modal-content logout-modal-dark">
|
<div class="modal-content action-modal-content logout-modal-dark">
|
||||||
@@ -210,8 +315,11 @@
|
|||||||
<h2 id="action-modal-title">Tùy chọn Scene</h2>
|
<h2 id="action-modal-title">Tùy chọn Scene</h2>
|
||||||
<p id="action-modal-desc">Bạn muốn thực hiện thao tác gì với scene này?</p>
|
<p id="action-modal-desc">Bạn muốn thực hiện thao tác gì với scene này?</p>
|
||||||
<div class="action-buttons">
|
<div class="action-buttons">
|
||||||
|
<button id="btn-edit-privacy-action" class="edit-btn-large" style="background: #6f42c1;">
|
||||||
|
<span class="icon">🔒</span> Chỉnh sửa privacy
|
||||||
|
</button>
|
||||||
<button id="btn-edit-action" class="edit-btn-large">
|
<button id="btn-edit-action" class="edit-btn-large">
|
||||||
<span class="icon">✏️</span> Chỉnh sửa thông tin
|
<span class="icon">✏️</span> Chế độ sửa scene
|
||||||
</button>
|
</button>
|
||||||
<button id="btn-delete-action" class="delete-btn-large">
|
<button id="btn-delete-action" class="delete-btn-large">
|
||||||
<span class="icon">🗑️</span> Xóa vĩnh viễn
|
<span class="icon">🗑️</span> Xóa vĩnh viễn
|
||||||
|
|||||||
+271
-5
@@ -10,11 +10,22 @@ let miniMapMarker = null;
|
|||||||
let systemSettings = { timezone: 'Asia/Ho_Chi_Minh', language: 'vi' };
|
let systemSettings = { timezone: 'Asia/Ho_Chi_Minh', language: 'vi' };
|
||||||
let returnToDashboardAfterEdit = false;
|
let returnToDashboardAfterEdit = false;
|
||||||
let assetIdToDelete = null;
|
let assetIdToDelete = null;
|
||||||
|
let editMiniMap = null;
|
||||||
|
let editMiniMapMarker = null;
|
||||||
|
let currentEditingScene = null; // Lưu object scene đang sửa để quản lý chia sẻ
|
||||||
|
let sharedUsersData = []; // [{id, username, email}]
|
||||||
|
let sharedEmailsData = []; // [email]
|
||||||
|
|
||||||
// Initialize when DOM is ready
|
// Initialize when DOM is ready
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
try {
|
try {
|
||||||
console.log("--- Bắt đầu khởi tạo Frontend ---");
|
console.log("--- Bắt đầu khởi tạo Frontend ---");
|
||||||
|
|
||||||
|
// 0. Kiểm tra tham số URL để truy cập trực tiếp
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const urlSceneId = urlParams.get('sceneId');
|
||||||
|
const urlToken = urlParams.get('token');
|
||||||
|
|
||||||
// Ưu tiên nạp cấu hình hệ thống trước
|
// Ưu tiên nạp cấu hình hệ thống trước
|
||||||
fetchSystemSettings();
|
fetchSystemSettings();
|
||||||
|
|
||||||
@@ -26,8 +37,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Chạy tuần tự để tránh xung đột luồng xử lý
|
// Chạy tuần tự để tránh xung đột luồng xử lý
|
||||||
checkAuthStatus(); // 2. Kiểm tra đăng nhập
|
checkAuthStatus(); // 2. Kiểm tra đăng nhập
|
||||||
|
|
||||||
// 3. Khôi phục cảnh đang xem nếu có (sau khi người dùng reload trang)
|
// 3. Xử lý logic vào thẳng Scene hoặc khôi phục trang
|
||||||
|
if (urlSceneId) {
|
||||||
|
console.log(`[Direct Access] Opening scene ${urlSceneId} from URL`);
|
||||||
|
openScene(urlSceneId, urlToken ? 'shared' : null, urlToken);
|
||||||
|
} else {
|
||||||
restoreActiveScene();
|
restoreActiveScene();
|
||||||
|
}
|
||||||
|
|
||||||
// Đảm bảo map đã sẵn sàng trước khi nạp data
|
// Đảm bảo map đã sẵn sàng trước khi nạp data
|
||||||
if (map) {
|
if (map) {
|
||||||
@@ -662,16 +678,25 @@ async function handleEditDeleteScene(scene) {
|
|||||||
const modal = document.getElementById('action-choice-modal');
|
const modal = document.getElementById('action-choice-modal');
|
||||||
const title = document.getElementById('action-modal-title');
|
const title = document.getElementById('action-modal-title');
|
||||||
const editBtn = document.getElementById('btn-edit-action');
|
const editBtn = document.getElementById('btn-edit-action');
|
||||||
|
const editPrivacyBtn = document.getElementById('btn-edit-privacy-action');
|
||||||
const deleteBtn = document.getElementById('btn-delete-action');
|
const deleteBtn = document.getElementById('btn-delete-action');
|
||||||
|
|
||||||
title.innerText = `Scene: ${scene.title}`;
|
title.innerText = `Scene: ${scene.title}`;
|
||||||
modal.style.display = 'flex';
|
modal.style.display = 'flex';
|
||||||
|
|
||||||
|
// Hành động Chỉnh sửa privacy
|
||||||
|
editPrivacyBtn.onclick = () => {
|
||||||
|
returnToDashboardAfterEdit = false;
|
||||||
|
closeActionModal();
|
||||||
|
// Mở modal metadata, false vì ảnh trên map luôn là ảnh mẹ (không phải child)
|
||||||
|
openEditMetadataModal(scene, false);
|
||||||
|
};
|
||||||
|
|
||||||
// Gán sự kiện cho nút Sửa
|
// Gán sự kiện cho nút Sửa
|
||||||
editBtn.onclick = () => {
|
editBtn.onclick = () => {
|
||||||
returnToDashboardAfterEdit = false;
|
returnToDashboardAfterEdit = false;
|
||||||
closeActionModal();
|
closeActionModal();
|
||||||
openEditSceneModal(scene);
|
openEditMetadataModal(scene, false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Gán sự kiện cho nút Xóa
|
// Gán sự kiện cho nút Xóa
|
||||||
@@ -805,9 +830,22 @@ async function openScene(sceneId, privacy, shareToken, force = false, initialPit
|
|||||||
initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId, initialPitch, initialYaw);
|
initPanoramaViewer(secureImageUrl, hotspots || [], sceneOwnerId, initialPitch, initialYaw);
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
if (typeof closeViewer === 'function') closeViewer();
|
||||||
|
|
||||||
localStorage.removeItem('activeSceneId');
|
localStorage.removeItem('activeSceneId');
|
||||||
localStorage.removeItem('activeScenePrivacy');
|
localStorage.removeItem('activeScenePrivacy');
|
||||||
localStorage.removeItem('activeSceneToken');
|
localStorage.removeItem('activeSceneToken');
|
||||||
|
|
||||||
|
// Kiểm tra nếu đang truy cập qua link trực tiếp (URL có sceneId) mà gặp lỗi (do xóa token hoặc token không hợp lệ)
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
if (urlParams.has('sceneId')) {
|
||||||
|
alert("Bạn không có quyền truy cập hoặc liên kết chia sẻ đã hết hạn. Quay về bản đồ công cộng.");
|
||||||
|
// Xóa toàn bộ tham số URL và tải lại trang để làm mới trạng thái (về trang chủ dành cho khách)
|
||||||
|
window.history.replaceState({}, document.title, window.location.pathname);
|
||||||
|
location.reload();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
alert(error.message);
|
alert(error.message);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1275,7 +1313,8 @@ async function loadMyAssets() {
|
|||||||
const editButton = document.createElement('button');
|
const editButton = document.createElement('button');
|
||||||
editButton.className = 'edit-btn-small';
|
editButton.className = 'edit-btn-small';
|
||||||
editButton.innerText = 'Sửa Scene';
|
editButton.innerText = 'Sửa Scene';
|
||||||
editButton.addEventListener('click', () => openEditFromMedia(scene)); // Pass scene object directly
|
const isChild = asset.parentScenes && asset.parentScenes.length > 0;
|
||||||
|
editButton.addEventListener('click', () => openEditFromMedia(scene, isChild));
|
||||||
card.querySelector('.media-actions').appendChild(editButton);
|
card.querySelector('.media-actions').appendChild(editButton);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1362,16 +1401,243 @@ window.closeSuccessModal = function(e) {
|
|||||||
modal.style.display = 'none';
|
modal.style.display = 'none';
|
||||||
};
|
};
|
||||||
|
|
||||||
window.openEditFromMedia = function(scene) {
|
window.openEditFromMedia = function(scene, isChild = false) {
|
||||||
if (!scene || !scene._id) {
|
if (!scene || !scene._id) {
|
||||||
alert("Không thể chỉnh sửa: Ảnh này không được gắn với một Scene hợp lệ.");
|
alert("Không thể chỉnh sửa: Ảnh này không được gắn với một Scene hợp lệ.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
returnToDashboardAfterEdit = true;
|
returnToDashboardAfterEdit = true;
|
||||||
closeDashboard();
|
closeDashboard();
|
||||||
openEditSceneModal(scene);
|
openEditMetadataModal(scene, isChild);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mở Modal sửa thông tin Metadata chuyên biệt
|
||||||
|
*/
|
||||||
|
window.openEditMetadataModal = function(scene, isChild = false) {
|
||||||
|
currentEditingScene = scene; // Lưu lại để dùng cho chia sẻ
|
||||||
|
// Load dữ liệu chia sẻ hiện tại
|
||||||
|
sharedUsersData = scene.sharedWith || [];
|
||||||
|
sharedEmailsData = scene.sharedEmails || [];
|
||||||
|
|
||||||
|
document.getElementById('edit-modal-scene-id').value = scene._id;
|
||||||
|
document.getElementById('edit-modal-title').value = scene.name || scene.title || '';
|
||||||
|
document.getElementById('edit-modal-description').value = scene.description || '';
|
||||||
|
|
||||||
|
const lat = scene.gps?.lat || scene.lat;
|
||||||
|
const lng = scene.gps?.lng || scene.lng;
|
||||||
|
document.getElementById('edit-modal-lat').value = lat;
|
||||||
|
document.getElementById('edit-modal-lng').value = lng;
|
||||||
|
|
||||||
|
// Xử lý logic Privacy cho Cảnh con
|
||||||
|
const privacySelect = document.getElementById('edit-modal-privacy');
|
||||||
|
const childInfo = document.getElementById('edit-child-privacy-info');
|
||||||
|
|
||||||
|
if (isChild) {
|
||||||
|
privacySelect.value = scene.privacy;
|
||||||
|
privacySelect.disabled = true;
|
||||||
|
childInfo.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
privacySelect.value = scene.privacy;
|
||||||
|
privacySelect.disabled = false;
|
||||||
|
childInfo.style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
handleEditPrivacyChange(); // Cập nhật hiển thị nút bánh răng
|
||||||
|
|
||||||
|
document.getElementById('edit-scene-metadata-modal').style.display = 'flex';
|
||||||
|
|
||||||
|
// Khởi tạo Mini Map tại vị trí hiện tại của Scene
|
||||||
|
setTimeout(() => initEditSceneMiniMap(lat, lng), 100);
|
||||||
|
};
|
||||||
|
|
||||||
|
function closeEditMetadataModal() {
|
||||||
|
document.getElementById('edit-scene-metadata-modal').style.display = 'none';
|
||||||
|
if (returnToDashboardAfterEdit) {
|
||||||
|
returnToDashboardAfterEdit = false;
|
||||||
|
openDashboard();
|
||||||
|
openDashboardTab('media-library');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function initEditSceneMiniMap(lat, lng) {
|
||||||
|
if (editMiniMap) {
|
||||||
|
editMiniMap.setView([lat, lng], 16);
|
||||||
|
if (editMiniMapMarker) editMiniMapMarker.setLatLng([lat, lng]);
|
||||||
|
editMiniMap.invalidateSize();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
editMiniMap = L.map('edit-mini-map').setView([lat, lng], 16);
|
||||||
|
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png').addTo(editMiniMap);
|
||||||
|
|
||||||
|
editMiniMapMarker = L.marker([lat, lng], { draggable: true }).addTo(editMiniMap);
|
||||||
|
|
||||||
|
editMiniMap.on('click', (e) => {
|
||||||
|
const { lat, lng } = e.latlng;
|
||||||
|
editMiniMapMarker.setLatLng([lat, lng]);
|
||||||
|
document.getElementById('edit-modal-lat').value = lat.toFixed(6);
|
||||||
|
document.getElementById('edit-modal-lng').value = lng.toFixed(6);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xử lý khi thay đổi Dropdown Privacy trong Modal sửa
|
||||||
|
*/
|
||||||
|
window.handleEditPrivacyChange = function() {
|
||||||
|
const privacy = document.getElementById('edit-modal-privacy').value;
|
||||||
|
const settingsBtn = document.getElementById('btn-edit-privacy-settings');
|
||||||
|
const isChild = document.getElementById('edit-modal-privacy').disabled;
|
||||||
|
|
||||||
|
if (!isChild && (privacy === 'member' || privacy === 'shared')) {
|
||||||
|
settingsBtn.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
settingsBtn.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mở modal cài đặt chi tiết dựa trên loại Privacy
|
||||||
|
*/
|
||||||
|
window.openPrivacySettingsModal = function() {
|
||||||
|
const privacy = document.getElementById('edit-modal-privacy').value;
|
||||||
|
if (privacy === 'member') {
|
||||||
|
renderSharedList();
|
||||||
|
document.getElementById('share-member-modal').style.display = 'flex';
|
||||||
|
} else if (privacy === 'shared') {
|
||||||
|
const baseUrl = window.location.origin + window.location.pathname;
|
||||||
|
const token = currentEditingScene.shareToken || 'đang_tạo_mới...';
|
||||||
|
document.getElementById('shared-link-input').value = `${baseUrl}?sceneId=${currentEditingScene._id}&token=${token}`;
|
||||||
|
document.getElementById('share-link-modal').style.display = 'flex';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tìm kiếm người dùng để chia sẻ
|
||||||
|
*/
|
||||||
|
window.searchUsersToShare = async function(query) {
|
||||||
|
const dropdown = document.getElementById('search-results-dropdown');
|
||||||
|
if (!query || query.length < 2) {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/users/search?q=${encodeURIComponent(query)}`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const users = await res.json();
|
||||||
|
|
||||||
|
dropdown.innerHTML = '';
|
||||||
|
if (users.length > 0) {
|
||||||
|
users.forEach(user => {
|
||||||
|
const item = document.createElement('div');
|
||||||
|
item.className = 'search-item';
|
||||||
|
item.innerText = `${user.username} (${user.email})`;
|
||||||
|
item.onclick = () => addMemberToShare(user);
|
||||||
|
dropdown.appendChild(item);
|
||||||
|
});
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
// Nếu không tìm thấy user, cho phép thêm email thủ công
|
||||||
|
if (query.includes('@')) {
|
||||||
|
dropdown.innerHTML = `<div class="search-item" onclick="addEmailToShare('${query}')">Thêm email: ${query}</div>`;
|
||||||
|
dropdown.style.display = 'block';
|
||||||
|
} else {
|
||||||
|
dropdown.style.display = 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) { console.error(e); }
|
||||||
|
};
|
||||||
|
|
||||||
|
function addMemberToShare(user) {
|
||||||
|
if (!sharedUsersData.some(u => (u._id || u) === user._id)) {
|
||||||
|
sharedUsersData.push(user);
|
||||||
|
renderSharedList();
|
||||||
|
}
|
||||||
|
document.getElementById('share-user-search').value = '';
|
||||||
|
document.getElementById('search-results-dropdown').style.display = 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEmailToShare = function(email) {
|
||||||
|
if (!sharedEmailsData.includes(email)) {
|
||||||
|
sharedEmailsData.push(email);
|
||||||
|
renderSharedList();
|
||||||
|
}
|
||||||
|
document.getElementById('share-user-search').value = '';
|
||||||
|
document.getElementById('search-results-dropdown').style.display = 'none';
|
||||||
|
};
|
||||||
|
|
||||||
|
function renderSharedList() {
|
||||||
|
const list = document.getElementById('current-shared-list');
|
||||||
|
list.innerHTML = '';
|
||||||
|
|
||||||
|
sharedUsersData.forEach(user => {
|
||||||
|
const name = user.username || 'User';
|
||||||
|
list.innerHTML += `<div class="share-list-item">👤 ${name} <span class="remove-share-btn" onclick="removeShared('user', '${user._id || user}')">×</span></div>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
sharedEmailsData.forEach(email => {
|
||||||
|
list.innerHTML += `<div class="share-list-item">📧 ${email} <span class="remove-share-btn" onclick="removeShared('email', '${email}')">×</span></div>`;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.removeShared = function(type, id) {
|
||||||
|
if (type === 'user') sharedUsersData = sharedUsersData.filter(u => (u._id || u) !== id);
|
||||||
|
else sharedEmailsData = sharedEmailsData.filter(e => e !== id);
|
||||||
|
renderSharedList();
|
||||||
|
};
|
||||||
|
|
||||||
|
window.closeShareMemberModal = () => document.getElementById('share-member-modal').style.display = 'none';
|
||||||
|
window.closeShareLinkModal = () => document.getElementById('share-link-modal').style.display = 'none';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Copy link chia sẻ và đóng modal
|
||||||
|
*/
|
||||||
|
window.copySharedLink = function() {
|
||||||
|
const linkInput = document.getElementById('shared-link-input');
|
||||||
|
linkInput.select();
|
||||||
|
navigator.clipboard.writeText(linkInput.value).then(() => {
|
||||||
|
showSuccessModal("Đã sao chép liên kết vào bộ nhớ!");
|
||||||
|
closeShareLinkModal();
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
async function submitEditScene(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const id = document.getElementById('edit-modal-scene-id').value;
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
|
||||||
|
// Sử dụng FormData vì API Backend hiện tại đang dùng Multer
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('title', document.getElementById('edit-modal-title').value);
|
||||||
|
formData.append('description', document.getElementById('edit-modal-description').value);
|
||||||
|
formData.append('lat', document.getElementById('edit-modal-lat').value);
|
||||||
|
formData.append('lng', document.getElementById('edit-modal-lng').value);
|
||||||
|
formData.append('privacy', document.getElementById('edit-modal-privacy').value);
|
||||||
|
formData.append('shareExpireDays', document.getElementById('share-link-expire').value);
|
||||||
|
// Đính kèm dữ liệu chia sẻ nâng cao
|
||||||
|
formData.append('sharedWithUsers', JSON.stringify(sharedUsersData.map(u => u._id || u)));
|
||||||
|
formData.append('sharedEmails', JSON.stringify(sharedEmailsData));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/scenes/${id}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (!res.ok) throw new Error(data.message);
|
||||||
|
|
||||||
|
showSuccessModal("Đã cập nhật thông tin cảnh thành công!");
|
||||||
|
closeEditMetadataModal();
|
||||||
|
loadScenes();
|
||||||
|
} catch (err) {
|
||||||
|
alert("Lỗi cập nhật: " + err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Opens a specific tab within the dashboard.
|
* Opens a specific tab within the dashboard.
|
||||||
* @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').
|
* @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes').
|
||||||
|
|||||||
Reference in New Issue
Block a user