diff --git a/backend/models/Setting.js b/backend/models/Setting.js new file mode 100644 index 0000000..4f6ce42 --- /dev/null +++ b/backend/models/Setting.js @@ -0,0 +1,15 @@ +const mongoose = require('mongoose'); + +const settingSchema = new mongoose.Schema({ + timezone: { + type: String, + default: 'Asia/Ho_Chi_Minh' + }, + language: { + type: String, + enum: ['vi', 'en'], + default: 'vi' + } +}, { timestamps: true }); + +module.exports = mongoose.model('Setting', settingSchema); \ No newline at end of file diff --git a/backend/routes/apiRoutes.js b/backend/routes/apiRoutes.js index 102c910..c6608f6 100644 --- a/backend/routes/apiRoutes.js +++ b/backend/routes/apiRoutes.js @@ -8,6 +8,7 @@ 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 Setting = require('../models/Setting'); const { protect, optionalAuth } = require('../middlewares/authMiddleware'); const { verifyReferer, setNoCacheHeaders } = require('../middlewares/securityMiddleware'); @@ -515,6 +516,115 @@ router.delete('/scenes/:id', protect, async (req, res) => { } }); +/** + * @route GET /api/me/profile + * @desc Lấy thông tin hồ sơ người dùng hiện tại + * @access Private + */ +router.get('/me/profile', protect, async (req, res) => { + try { + const user = await User.findById(req.user._id).select('-password'); + res.json(user); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route PUT /api/me/profile + * @desc Cập nhật hồ sơ (đổi tên, mật khẩu) + * @access Private + */ +router.put('/me/profile', protect, async (req, res) => { + try { + const user = await User.findById(req.user._id); + if (!user) return res.status(404).json({ message: 'User not found' }); + + if (req.body.username) user.username = req.body.username; + if (req.body.password) user.password = req.body.password; // Hook pre-save sẽ tự hash + + await user.save(); + res.json({ + message: 'Hồ sơ đã được cập nhật', + user: { id: user._id, username: user.username, role: user.role } + }); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route GET /api/me/scenes + * @desc Lấy danh sách các cảnh mẹ do người dùng tạo + * @access Private + */ +router.get('/me/scenes', protect, async (req, res) => { + try { + const scenes = await Scene.find({ createdBy: req.user._id }) + .populate('assetId') + .sort({ createdAt: -1 }); + res.json(scenes); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route GET /api/me/assets + * @desc Lấy danh sách media của người dùng + * @access Private + */ +router.get('/me/assets', protect, async (req, res) => { + try { + const assets = await Asset.find({ uploadedBy: req.user._id }).sort({ createdAt: -1 }); + res.json(assets); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route GET /api/admin/users + * @desc Lấy toàn bộ danh sách người dùng (Chỉ Admin) + * @access Private (Admin) + */ +router.get('/admin/users', protect, async (req, res) => { + if (req.user.role !== 'Chủ sở hữu') { + return res.status(403).json({ message: 'Bạn không có quyền truy cập quản trị' }); + } + try { + const users = await User.find({}).select('-password').sort({ createdAt: -1 }); + res.json(users); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +/** + * @route GET/PUT /api/system/settings + * @desc Quản lý thiết lập hệ thống + * @access Private (Admin) + */ +router.get('/system/settings', optionalAuth, async (req, res) => { + try { + let settings = await Setting.findOne(); + if (!settings) settings = await Setting.create({}); + res.json(settings); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + +router.put('/system/settings', protect, async (req, res) => { + if (req.user.role !== 'Chủ sở hữu') return res.status(403).json({ message: 'Forbidden' }); + try { + const settings = await Setting.findOneAndUpdate({}, req.body, { new: true, upsert: true }); + res.json(settings); + } catch (error) { + res.status(500).json({ message: error.message }); + } +}); + /** * @route POST /api/maintenance/reset-all * @desc Wipe all scenes, assets, and physical files (DANGEROUS: For dev reset only) diff --git a/frontend/css/style.css b/frontend/css/style.css index 0495a73..1b2195b 100644 --- a/frontend/css/style.css +++ b/frontend/css/style.css @@ -17,54 +17,6 @@ html, body { z-index: 1; } -/* Floating panels on top of the map */ -.floating-panel { - position: absolute; - top: 20px; - right: 20px; - background: rgba(255, 255, 255, 0.95); - padding: 15px; - border-radius: 8px; - box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15); - z-index: 1000; - width: 280px; -} - -.floating-panel h3 { - margin-bottom: 10px; - font-size: 16px; - color: #333; -} - -.floating-panel input { - width: 100%; - padding: 8px; - margin-bottom: 8px; - border: 1px solid #ccc; - border-radius: 4px; -} - -.btn-group { - display: flex; - gap: 10px; -} - -.btn-group button, .floating-panel button { - flex: 1; - padding: 8px; - background-color: #007bff; - color: white; - border: none; - border-radius: 4px; - cursor: pointer; - font-weight: bold; - transition: background-color 0.2s; -} - -.btn-group button:hover, .floating-panel button:hover { - background-color: #0056b3; -} - /* Modal Styling */ .modal { display: none; @@ -92,14 +44,18 @@ html, body { .close-btn { position: absolute; top: 15px; - right: 20px; - font-size: 24px; + right: 25px; + font-size: 30px; cursor: pointer; - color: #666; + color: #fff; /* Chuyển sang màu trắng để nổi bật trên nền tối */ + z-index: 5000; /* Đảm bảo nằm trên cùng của tất cả các pane */ + transition: all 0.2s ease; + text-shadow: 0 0 10px rgba(0,0,0,0.5); } .close-btn:hover { - color: #000; + color: #ff4d4d; /* Chuyển sang màu đỏ khi di chuột để dễ nhận biết */ + transform: scale(1.1); } .form-group { @@ -176,10 +132,331 @@ html, body { 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; + align-items: center; justify-content: center; z-index: 4000; } +/* Top Bar */ +#top-bar { + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 60px; /* Fixed height */ + background: rgba(20, 20, 20, 0.8); /* Màu tối transparent */ + color: #fff; + box-shadow: 0 2px 15px rgba(0, 0, 0, 0.4); + z-index: 1500; /* Above map, below modals */ + display: flex; + justify-content: space-between; + align-items: center; + padding: 0 20px; +} + +.app-brand { + display: flex; + align-items: center; + gap: 10px; +} + +.app-brand h1 { + font-size: 20px; + color: #fff; + margin: 0; +} + +#user-controls { + position: relative; +} + +#user-avatar { + width: 40px; + height: 40px; + border-radius: 50%; + background: #007bff; + color: white; + display: flex; + justify-content: center; + align-items: center; + font-weight: bold; + font-size: 18px; + cursor: pointer; + box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2); +} + +#user-dropdown { + display: none; /* Hidden by default */ + position: absolute; + top: 50px; /* Below the avatar */ + right: -20px; /* Canh sát mép màn hình, bù trừ padding của top-bar */ + background: rgba(30, 30, 30, 0.98); + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.5); + border-radius: 8px; + padding: 10px 0; /* Remove horizontal padding to let hover cover full width */ + width: auto; /* Tự động điều chỉnh theo nội dung */ + z-index: 1600; /* Above top-bar */ + border: 1px solid rgba(255, 255, 255, 0.1); + min-width: 180px; /* Đảm bảo không quá nhỏ */ +} + +#user-dropdown.show { + display: block; +} + +#user-dropdown h3 { + margin: 10px 20px; + font-size: 16px; + color: #fff; /* Giữ màu trắng cho các tiêu đề khác trong dropdown nếu có */ + /* Nếu đây là tiêu đề "Welcome", nó sẽ bị ẩn bởi quy tắc bên dưới */ +} + +#user-dropdown input { + width: calc(100% - 40px); + margin: 0 20px 10px 20px; + padding: 8px; + border: 1px solid #444; + background: #222; + color: #fff; + border-radius: 4px; +} + +#user-dropdown .btn-group { + display: flex; + flex-direction: column; /* Sắp xếp theo cột */ + gap: 0; +} + +#user-dropdown .btn-group button, #user-dropdown button { + width: 100%; + padding: 10px 15px; /* Giảm padding để hộp nhỏ gọn hơn */ + background-color: transparent; /* Không sử dụng background button */ + color: #ccc; + text-align: left; /* Căn lề trái giống menu text */ + border: none; + border-radius: 0; + cursor: pointer; + font-weight: 500; + font-size: 14px; + transition: all 0.2s ease; + border-bottom: 1px solid rgba(255, 255, 255, 0.05); +} + +#user-dropdown .btn-group button:hover, #user-dropdown button:hover { + background-color: rgba(255, 255, 255, 0.1); /* Hiệu ứng hover nhẹ */ + color: #fff; +} + +#user-dropdown button:last-child { + border-bottom: none; +} + +/* Dashboard Overlay */ +#dashboard-overlay { + /* Inherits .modal-overlay styles */ + z-index: 4500; /* Above all other modals */ +} + +.dashboard-content { + /* Các thuộc tính đã được sửa ở lần trước */ + max-width: 800px !important; /* Mở rộng chiều rộng tối đa lên 1800px */ + width: 95% !important; + height: 85vh; + overflow: hidden; /* Cắt bỏ phần tràn nếu có */ + position: relative; /* Làm mốc cho nút close-btn absolute */ + display: flex !important; + flex-direction: row !important; /* Ép buộc luôn nằm ngang */ + flex-wrap: nowrap; /* Ngăn chặn các thành phần nhảy xuống hàng dọc */ + gap: 0; /* Loại bỏ khoảng hở giữa 2 pane để tạo khối thống nhất */ + background: transparent !important; /* Loại bỏ nền chung của panel */ + box-shadow: none !important; /* Loại bỏ bóng chung */ + padding: 0; +} + +.dashboard-tabs { + /* Các thuộc tính đã được sửa ở lần trước */ + display: flex; + flex-direction: column; /* Danh mục menu dọc */ + background: rgba(30, 30, 30, 0.9); + backdrop-filter: blur(10px); + width: 220px; + flex-shrink: 0; /* Không cho phép menu bị thu nhỏ */ + border-radius: 15px 0 0 15px; /* Chỉ bo góc bên trái */ + padding: 20px 10px; + gap: 2px; /* Khoảng cách hẹp giữa các dòng menu */ + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + border: 1px solid rgba(255, 255, 255, 0.1); + height: 100%; /* Đảm bảo sidebar chiếm toàn bộ chiều cao của dashboard-content */ + overflow-y: auto; /* Cho phép cuộn nếu nội dung sidebar quá dài */ +} + +.dashboard-tabs .tab-btn { + /* Các thuộc tính đã được sửa ở lần trước */ + padding: 12px 15px; + border: none; + border-radius: 10px; + background: none; + cursor: pointer; + font-weight: 400; + color: #aaa; + text-align: left; + transition: all 0.3s ease; + flex: none; /* Ngăn button thay đổi kích thước theo chiều cao của dashboard */ +} + +.dashboard-tabs .tab-btn.active { + /* Các thuộc tính đã được sửa ở lần trước */ + color: #fff; + background: rgba(255, 255, 255, 0.1); + box-shadow: inset 0 0 10px rgba(255, 255, 255, 0.05); +} + +.dashboard-tabs .tab-btn:hover:not(.active) { + background: rgba(255, 255, 255, 0.05); + color: #fff; +} + +#dashboard-tab-content { + flex: 1; /* Quan trọng: Giúp phần nội dung chiếm toàn bộ không gian còn lại bên phải */ + min-width: 0; /* Ngăn chặn nội dung bên trong đẩy bung layout */ + height: 100%; + display: block; + overflow-y: auto; +} + +.dashboard-tab-pane { + display: none !important; /* Ẩn tuyệt đối các tab không hoạt động */ + width: 100%; + height: 100%; /* Đảm bảo mỗi tab pane chiếm toàn bộ chiều cao của #dashboard-tab-content */ + background: rgba(30, 30, 30, 0.9); /* Đồng nhất màu tối với sidebar */ + color: #fff; /* Chuyển màu chữ sang trắng cho dark mode */ + backdrop-filter: blur(5px); + border-radius: 0 15px 15px 0; /* Chỉ bo góc bên phải */ + padding: 30px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + overflow-y: auto; + border: 1px solid rgba(255, 255, 255, 0.1); + border-left: 1px solid rgba(255, 255, 255, 0.05); /* Đường ngăn cách mờ giữa 2 pane */ +} + +.dashboard-tab-pane.active { + display: block !important; /* Chỉ tab active mới được phép hiển thị */ +} + +/* Điều chỉnh các phần tử bên trong tab pane cho phù hợp với nền tối */ +.dashboard-tab-pane h3 { + color: #fff; + margin-bottom: 20px; +} + +.dashboard-tab-pane label { + color: #ccc; +} + +/* Dashboard List Styles */ +.dashboard-list { + display: flex; + flex-direction: column; + gap: 12px; + margin-top: 10px; +} + +.dashboard-item { + background: rgba(255, 255, 255, 0.05); + padding: 15px; + border-radius: 10px; + display: flex; + justify-content: space-between; + align-items: center; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: background 0.2s; +} + +.dashboard-item:hover { + background: rgba(255, 255, 255, 0.08); +} + +.item-info { + flex: 1; /* Chiếm toàn bộ không gian bên trái */ + display: flex; + flex-direction: column; + gap: 4px; + padding-right: 20px; /* Khoảng cách an toàn với các nút bấm */ +} + +.item-info strong { color: #fff; font-size: 16px; } +.item-info span { color: #888; font-size: 13px; } + +.item-actions { display: flex; gap: 10px; flex-shrink: 0; } +.item-actions button { + padding: 8px 16px; + border: none; + border-radius: 6px; + cursor: pointer; + font-weight: 600; + font-size: 13px; + transition: opacity 0.2s; +} + +.edit-btn { background: #007bff; color: white; } +.delete-btn { background: #dc3545; color: white; } +.item-actions button:hover { opacity: 0.9; } + +/* Tùy chỉnh các dòng trong Sidebar */ +.sidebar-user-header { + display: flex; + align-items: center; + gap: 12px; + padding: 0 10px 10px 10px; +} + +.avatar-circle { + width: 45px; + height: 45px; + border-radius: 50%; + background: #007bff; + color: white; + display: flex; + justify-content: center; + align-items: center; + font-weight: 600; + font-size: 20px; +} + +.user-name-text { + font-size: 16px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.user-status-text { + font-size: 13px; + color: #888; + padding: 0 10px 15px 10px; +} + +.sidebar-divider { + border: none; + border-top: 1px solid rgba(255, 255, 255, 0.1); + margin: 5px 10px 15px 10px; +} + +.dashboard-tabs .tab-btn.logout-item { + margin-top: 10px; + color: #ff4d4d; +} + +.dashboard-tabs .tab-btn.logout-item:hover { + background: rgba(255, 77, 77, 0.1); + color: #ff4d4d; +} + +.admin-only { + /* Style for admin-specific elements, or hide by default */ + display: none; +} .modal-content { background: #fff; padding: 20px; diff --git a/frontend/favicon.ico b/frontend/favicon.ico new file mode 100644 index 0000000..0a42af9 Binary files /dev/null and b/frontend/favicon.ico differ diff --git a/frontend/index.html b/frontend/index.html index dfb08c7..e0ce405 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -19,20 +19,111 @@
- -Đang tải danh sách...
'; + + try { + const res = await fetch(`${API_BASE_URL}/me/scenes`, { + headers: { 'Authorization': `Bearer ${token}` } + }); + const scenes = await res.json(); + if (!res.ok) throw new Error(scenes.message); + + listContainer.innerHTML = ''; + if (scenes.length === 0) { + listContainer.innerHTML = 'Bạn chưa tạo scene nào.
'; + return; + } + + scenes.forEach(scene => { + const item = document.createElement('div'); + item.className = 'dashboard-item'; + item.innerHTML = ` +Lỗi: ${e.message}
`; + } +} + +/** + * Opens a specific tab within the dashboard. + * @param {string} tabName - The ID of the tab pane to open (e.g., 'profile', 'my-scenes'). + */ +function openDashboardTab(tabName) { + // Ẩn tất cả các tab pane + document.querySelectorAll('.dashboard-tab-pane').forEach(pane => { + pane.classList.remove('active'); + }); + // Bỏ active khỏi tất cả các nút tab + document.querySelectorAll('.dashboard-tabs .tab-btn').forEach(btn => { + btn.classList.remove('active'); + }); + + // Hiển thị tab pane được chọn + const selectedPane = document.getElementById(`tab-${tabName}`); + if (selectedPane) { + selectedPane.classList.add('active'); + // Nếu là tab profile, cập nhật nội dung + if (tabName === 'profile') { + updateProfileTabContent(); + } + if (tabName === 'my-scenes') { + loadMyScenes(); + } + } + + // Đánh dấu nút tab được chọn là active + const selectedTabButton = document.querySelector(`.dashboard-tabs .tab-btn[onclick="openDashboardTab('${tabName}')"]`); + if (selectedTabButton) { + selectedTabButton.classList.add('active'); + } + + // Cập nhật avatar initials khi mở dashboard + const username = localStorage.getItem('username'); + if (username) { + document.getElementById('avatar-initials').innerText = username.charAt(0).toUpperCase(); + } +}