Thêm tính năng hiển thị ảnh ở quản lí ảnh và media
This commit is contained in:
@@ -583,13 +583,95 @@ router.get('/me/scenes', protect, async (req, res) => {
|
|||||||
*/
|
*/
|
||||||
router.get('/me/assets', protect, async (req, res) => {
|
router.get('/me/assets', protect, async (req, res) => {
|
||||||
try {
|
try {
|
||||||
const assets = await Asset.find({ uploadedBy: req.user._id }).sort({ createdAt: -1 });
|
// Sử dụng Aggregation để lấy Asset kèm thông tin Scene và Parent Scene
|
||||||
|
const query = req.user.role === 'Chủ sở hữu' ? {} : { uploadedBy: req.user._id };
|
||||||
|
|
||||||
|
const assets = await Asset.aggregate([
|
||||||
|
{ $match: query },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'scenes', // Tên collection trong DB (thường là số nhiều)
|
||||||
|
localField: '_id',
|
||||||
|
foreignField: 'assetId',
|
||||||
|
as: 'linkedScene'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $unwind: { path: '$linkedScene', preserveNullAndEmptyArrays: true } },
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'hotspots',
|
||||||
|
localField: 'linkedScene._id',
|
||||||
|
foreignField: 'target_scene_id',
|
||||||
|
as: 'incomingHotspots'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$lookup: {
|
||||||
|
from: 'scenes',
|
||||||
|
localField: 'incomingHotspots.parent_scene_id',
|
||||||
|
foreignField: '_id',
|
||||||
|
as: 'parentScenes'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
$project: {
|
||||||
|
filePath: 0 // Bảo mật: Không trả về đường dẫn vật lý đầy đủ
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ $sort: { createdAt: -1 } }
|
||||||
|
]);
|
||||||
|
|
||||||
res.json(assets);
|
res.json(assets);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).json({ message: error.message });
|
res.status(500).json({ message: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @route DELETE /api/assets/:id
|
||||||
|
* @desc Xóa Asset + Xóa Scene liên quan (nếu có) + Xóa file vật lý
|
||||||
|
* @access Private (Chỉ người upload hoặc Admin)
|
||||||
|
*/
|
||||||
|
router.delete('/assets/:id', protect, async (req, res) => {
|
||||||
|
try {
|
||||||
|
const asset = await Asset.findById(req.params.id);
|
||||||
|
if (!asset) return res.status(404).json({ message: 'Ảnh không tồn tại' });
|
||||||
|
|
||||||
|
// Kiểm tra quyền: Người upload hoặc Admin (Chủ sở hữu)
|
||||||
|
const isOwner = asset.uploadedBy.toString() === req.user._id.toString();
|
||||||
|
const isAdmin = req.user.role === 'Chủ sở hữu' || req.user.role === 'admin';
|
||||||
|
|
||||||
|
if (!isOwner && !isAdmin) {
|
||||||
|
return res.status(403).json({ message: 'Bạn không có quyền xóa tập tin này' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Tìm và xóa Scene liên quan nếu có
|
||||||
|
const linkedScene = await Scene.findOne({ assetId: asset._id });
|
||||||
|
if (linkedScene) {
|
||||||
|
// Xóa toàn bộ hotspot trỏ đến hoặc xuất phát từ scene này
|
||||||
|
await Hotspot.deleteMany({
|
||||||
|
$or: [
|
||||||
|
{ parent_scene_id: linkedScene._id },
|
||||||
|
{ target_scene_id: linkedScene._id }
|
||||||
|
]
|
||||||
|
});
|
||||||
|
await Scene.findByIdAndDelete(linkedScene._id);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Xóa file vật lý trên disk
|
||||||
|
if (fs.existsSync(asset.filePath)) {
|
||||||
|
fs.unlinkSync(asset.filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Xóa Asset trong DB
|
||||||
|
await Asset.findByIdAndDelete(req.params.id);
|
||||||
|
|
||||||
|
res.json({ message: 'Đã xóa ảnh và các dữ liệu liên quan thành công' });
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ message: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @route GET /api/admin/users
|
* @route GET /api/admin/users
|
||||||
* @desc Lấy toàn bộ danh sách người dùng (Chỉ Admin)
|
* @desc Lấy toàn bộ danh sách người dùng (Chỉ Admin)
|
||||||
|
|||||||
@@ -689,3 +689,64 @@ html, body {
|
|||||||
visibility: visible;
|
visibility: visible;
|
||||||
top: -55px; /* Nhích lên một chút khi xuất hiện */
|
top: -55px; /* Nhích lên một chút khi xuất hiện */
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* --- Media Library Dashboard --- */
|
||||||
|
.dashboard-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card {
|
||||||
|
background: rgba(255, 255, 255, 0.05);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-card.trash-item { border-color: rgba(220, 53, 69, 0.4); }
|
||||||
|
|
||||||
|
.media-thumb {
|
||||||
|
position: relative;
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
background: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-thumb img { width: 100%; height: 100%; object-fit: cover; }
|
||||||
|
|
||||||
|
.badge-trash {
|
||||||
|
position: absolute; top: 8px; right: 8px;
|
||||||
|
background: #dc3545; color: white; font-size: 10px;
|
||||||
|
padding: 2px 6px; border-radius: 4px; font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.media-info { padding: 12px; flex: 1; }
|
||||||
|
.media-info strong { display: block; font-size: 15px; margin-bottom: 5px; }
|
||||||
|
.media-info .desc { font-size: 12px; color: #aaa; margin-bottom: 8px; }
|
||||||
|
.media-info .parent-link { font-size: 11px; color: #007bff; margin-bottom: 5px; }
|
||||||
|
.media-info .date { font-size: 10px; color: #666; }
|
||||||
|
|
||||||
|
.media-actions {
|
||||||
|
padding: 10px;
|
||||||
|
display: flex;
|
||||||
|
gap: 8px;
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn-small, .delete-btn-small {
|
||||||
|
flex: 1;
|
||||||
|
padding: 6px;
|
||||||
|
font-size: 11px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-btn-small { background: #28a745; color: white; }
|
||||||
|
.delete-btn-small { background: #dc3545; color: white; }
|
||||||
|
.media-actions button:hover { opacity: 0.8; }
|
||||||
|
|||||||
@@ -1200,6 +1200,114 @@ async function loadMyScenes() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tải và hiển thị kho ảnh/media của người dùng
|
||||||
|
*/
|
||||||
|
async function loadMyAssets() {
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
const gridContainer = document.getElementById('media-library-list');
|
||||||
|
const currentUserId = localStorage.getItem('userId');
|
||||||
|
const userRole = localStorage.getItem('role');
|
||||||
|
|
||||||
|
gridContainer.innerHTML = '<p>Đang tải kho ảnh...</p>';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/me/assets`, {
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const assets = await res.json();
|
||||||
|
if (!res.ok) throw new Error(assets.message);
|
||||||
|
|
||||||
|
gridContainer.innerHTML = '';
|
||||||
|
if (assets.length === 0) {
|
||||||
|
gridContainer.innerHTML = '<p>Kho ảnh trống.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
assets.forEach(asset => {
|
||||||
|
const scene = asset.linkedScene;
|
||||||
|
const isTrash = !scene;
|
||||||
|
const parentNames = asset.parentScenes?.map(p => p.name || p.title).join(', ');
|
||||||
|
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = `media-card ${isTrash ? 'trash-item' : ''}`;
|
||||||
|
|
||||||
|
// Build inner HTML without the onclick for edit/delete buttons
|
||||||
|
let innerHtml = `
|
||||||
|
<div class="media-thumb">
|
||||||
|
<img src="${API_BASE_URL}/assets/view/${asset._id}?token=${token}" alt="Thumbnail">
|
||||||
|
${isTrash ? '<span class="badge-trash">Ảnh rác</span>' : ''}
|
||||||
|
</div>
|
||||||
|
<div class="media-info">
|
||||||
|
<strong>${scene ? (scene.name || scene.title) : 'Chưa gắn Scene'}</strong>
|
||||||
|
<p class="desc">${scene?.description || 'Không có mô tả'}</p>
|
||||||
|
${parentNames ? `<p class="parent-link">🔗 Liên kết từ: ${parentNames}</p>` : ''}
|
||||||
|
<span class="date">Tải lên: ${formatSystemDate(asset.createdAt)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="media-actions">
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
card.innerHTML = innerHtml;
|
||||||
|
|
||||||
|
// Add edit button and its event listener separately
|
||||||
|
if (scene && asset.uploadedBy === currentUserId) {
|
||||||
|
const editButton = document.createElement('button');
|
||||||
|
editButton.className = 'edit-btn-small';
|
||||||
|
editButton.innerText = 'Sửa Scene';
|
||||||
|
editButton.addEventListener('click', () => openEditFromMedia(scene)); // Pass scene object directly
|
||||||
|
card.querySelector('.media-actions').appendChild(editButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add delete button and its event listener
|
||||||
|
if (asset.uploadedBy === currentUserId || (isTrash && userRole === 'Chủ sở hữu')) {
|
||||||
|
const deleteButton = document.createElement('button');
|
||||||
|
deleteButton.className = 'delete-btn-small';
|
||||||
|
deleteButton.innerText = 'Xóa';
|
||||||
|
deleteButton.addEventListener('click', () => deleteAsset(asset._id));
|
||||||
|
card.querySelector('.media-actions').appendChild(deleteButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
gridContainer.appendChild(card);
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
gridContainer.innerHTML = `<p style="color:#ff4d4d">Lỗi nạp media: ${e.message}</p>`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Xóa ảnh khỏi kho media
|
||||||
|
*/
|
||||||
|
window.deleteAsset = async function(assetId) {
|
||||||
|
if (!confirm('Bạn có chắc chắn muốn xóa ảnh này? Nếu ảnh đang được gắn vào một cảnh, cảnh đó cũng sẽ bị xóa vĩnh viễn.')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = localStorage.getItem('jwt');
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/assets/${assetId}`, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` }
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!res.ok) throw new Error(data.message);
|
||||||
|
|
||||||
|
alert(data.message);
|
||||||
|
loadMyAssets(); // Nạp lại kho ảnh
|
||||||
|
loadScenes(); // Nạp lại bản đồ nếu có scene bị xóa kèm theo
|
||||||
|
} catch (e) {
|
||||||
|
alert("Lỗi khi xóa: " + e.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.openEditFromMedia = function(scene) {
|
||||||
|
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ệ.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
openEditSceneModal(scene);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 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').
|
||||||
@@ -1225,6 +1333,9 @@ function openDashboardTab(tabName) {
|
|||||||
if (tabName === 'my-scenes') {
|
if (tabName === 'my-scenes') {
|
||||||
loadMyScenes();
|
loadMyScenes();
|
||||||
}
|
}
|
||||||
|
if (tabName === 'media-library') {
|
||||||
|
loadMyAssets();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Đánh dấu nút tab được chọn là active
|
// Đánh dấu nút tab được chọn là active
|
||||||
|
|||||||
Reference in New Issue
Block a user