diff --git a/public/assets/css/style.css b/public/assets/css/style.css index 92cfe35..e4f6038 100755 --- a/public/assets/css/style.css +++ b/public/assets/css/style.css @@ -145,10 +145,26 @@ code, pre { overflow: hidden; } +.sidebar::-webkit-scrollbar { + width: 4px; +} + +.sidebar::-webkit-scrollbar-thumb { + background: transparent; +} + +.sidebar:hover::-webkit-scrollbar-thumb { + background: var(--border-color); +} + .sidebar.collapsed { width: var(--sidebar-collapsed); } +.sidebar.collapsed .sidebar-header::after { + display: none; +} + .sidebar-header { display: flex; align-items: center; @@ -157,6 +173,18 @@ code, pre { border-bottom: 1px solid var(--border-color); height: var(--topbar-height); min-height: var(--topbar-height); + position: relative; +} + +.sidebar-header::after { + content: ''; + position: absolute; + bottom: -1px; + left: 1.25rem; + right: 1.25rem; + height: 2px; + background: linear-gradient(90deg, var(--accent), transparent); + border-radius: 1px; } .sidebar-brand { @@ -167,6 +195,7 @@ code, pre { font-weight: 700; font-size: 1.2rem; white-space: nowrap; + text-decoration: none; } .sidebar-brand i { @@ -175,7 +204,8 @@ code, pre { } .nav-item i, -.nav-sub-item i { +.nav-sub-item i, +.nav-item-toggle i { pointer-events: none; } @@ -251,19 +281,22 @@ code, pre { display: flex; align-items: center; gap: 0.75rem; - padding: 0.65rem 1.25rem; + padding: 0.6rem 1.25rem; + margin: 0 0.5rem; color: var(--text-secondary); - border-radius: 0; + border-radius: var(--radius-sm); transition: all var(--transition); - font-size: 0.92rem; + font-size: 0.9rem; white-space: nowrap; - border-left: 3px solid transparent; + text-decoration: none; + position: relative; } .nav-item i { width: 20px; text-align: center; flex-shrink: 0; + font-size: 1rem; } .nav-item:hover { @@ -274,23 +307,35 @@ code, pre { .nav-item.active { color: var(--accent); background: var(--accent-light); - border-left-color: var(--accent); - font-weight: 500; + font-weight: 600; +} + +.nav-item.active::before { + content: ''; + position: absolute; + left: -0.5rem; + top: 50%; + transform: translateY(-50%); + width: 3px; + height: 20px; + background: var(--accent); + border-radius: 0 2px 2px 0; } .nav-divider { height: 1px; background: var(--border-color); margin: 0.5rem 1rem; + opacity: 0.5; } .nav-section { - padding: 0.5rem 1.25rem; - font-size: 0.75rem; + padding: 0.6rem 1.25rem 0.35rem; + font-size: 0.7rem; text-transform: uppercase; - letter-spacing: 0.08em; + letter-spacing: 0.1em; color: var(--text-muted); - font-weight: 600; + font-weight: 700; white-space: nowrap; } @@ -312,6 +357,7 @@ code, pre { .sidebar.collapsed .nav-item { justify-content: center; padding: 0.65rem; + margin: 0 0.35rem; } /* ========================================================================== @@ -670,6 +716,14 @@ code, pre { box-shadow: var(--shadow-sm); } +.btn:disabled, +.btn.disabled { + opacity: 0.55; + cursor: not-allowed; + transform: none !important; + box-shadow: none !important; +} + .btn-primary { background: var(--accent); color: white; @@ -681,6 +735,26 @@ code, pre { color: white; } +.btn-primary:disabled, +.btn-primary.disabled { + background: var(--bg-tertiary); + color: var(--text-muted); + border-color: var(--border-color); + cursor: not-allowed; + transform: none; + box-shadow: none; + opacity: 0.6; +} + +.btn-primary:disabled:hover, +.btn-primary.disabled:hover { + background: var(--bg-tertiary); + color: var(--text-muted); + border-color: var(--border-color); + transform: none; + box-shadow: none; +} + .btn-secondary { background: var(--bg-tertiary); color: var(--text-primary); @@ -875,13 +949,45 @@ textarea.form-input { gap: 0.5rem; cursor: pointer; font-size: 0.9rem; + position: relative; + padding-left: 0; } .checkbox-label input[type="checkbox"] { - width: 16px; - height: 16px; - accent-color: var(--accent); - cursor: pointer; + position: absolute; + opacity: 0; + width: 0; + height: 0; + pointer-events: none; +} + +.checkbox-label .checkmark { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border: 2px solid var(--border-color); + border-radius: 4px; + background: var(--bg-input); + transition: all var(--transition); + flex-shrink: 0; + font-size: 0.65rem; + color: transparent; +} + +.checkbox-label:hover .checkmark { + border-color: var(--accent); +} + +.checkbox-label input[type="checkbox"]:checked + .checkmark { + background: var(--accent); + border-color: var(--accent); + color: #fff; +} + +.checkbox-label input[type="checkbox"]:focus-visible + .checkmark { + box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.2); } .font-mono { @@ -2340,6 +2446,7 @@ textarea.form-input { .sidebar.mobile-open.collapsed .nav-item { justify-content: flex-start; padding: 0.65rem 1.25rem; + margin: 0 0.5rem; } .main-area { @@ -2379,7 +2486,8 @@ textarea.form-input { } .nav-sublist { - background: rgba(0,0,0,0.1); + background: rgba(0,0,0,0.06); + margin: 0; } } @@ -2750,10 +2858,14 @@ textarea.form-input { gap: 0.5rem; } +.nav-item-accordion.expanded > .nav-item-toggle { + color: var(--text-primary); +} + .nav-chevron { margin-left: auto; - font-size: 0.7rem; - transition: transform 0.2s; + font-size: 0.65rem; + transition: transform 0.25s ease; color: var(--text-muted); } @@ -2765,18 +2877,22 @@ textarea.form-input { max-height: 0; overflow: hidden; transition: max-height 0.25s ease; - background: rgba(0,0,0,0.15); + background: rgba(0,0,0,0.08); + border-radius: 0 0 var(--radius-sm) var(--radius-sm); + margin: 0 0.5rem; } .nav-sub-item { display: flex; align-items: center; gap: 0.5rem; - padding: 0.4rem 1rem 0.4rem 2.8rem; + padding: 0.35rem 1rem 0.35rem 2.5rem; + margin: 0 0.25rem; font-size: 0.82rem; color: var(--text-secondary); text-decoration: none; - transition: background 0.1s; + transition: all 0.15s; + border-radius: var(--radius-sm); } .nav-sub-item:hover { @@ -2784,6 +2900,13 @@ textarea.form-input { color: var(--text-primary); } +.nav-sub-item i { + width: 16px; + text-align: center; + font-size: 0.75rem; + color: var(--text-muted); +} + .status-dot-sm { width: 6px; height: 6px; diff --git a/public/assets/js/admin-notifications.js b/public/assets/js/admin-notifications.js index 7d24e1d..ce2d492 100644 --- a/public/assets/js/admin-notifications.js +++ b/public/assets/js/admin-notifications.js @@ -38,3 +38,60 @@ function sendNotification() { btn.innerHTML = ' Send'; }); } + +let deleteId = null; + +function showDeleteModal(id, title) { + deleteId = id; + document.getElementById('deleteModalTitle').textContent = '"' + title + '"'; + document.getElementById('deleteModal').style.display = 'flex'; +} + +function closeDeleteModal() { + deleteId = null; + document.getElementById('deleteModal').style.display = 'none'; +} + +function executeDelete() { + if (!deleteId) return; + const btn = document.getElementById('deleteConfirmBtn'); + btn.disabled = true; + btn.innerHTML = ' Deleting...'; + + const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; + fetch('/admin/notifications/' + deleteId + '/delete', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': csrf, + }, + body: '_csrf_token=' + encodeURIComponent(csrf), + }) + .then(r => r.json()) + .then(data => { + showToast(data.success ? 'success' : 'error', data.message); + closeDeleteModal(); + if (data.success) location.reload(); + }) + .catch(() => { + showToast('error', 'Failed to delete notification'); + closeDeleteModal(); + }); +} + +function confirmResend(id) { + const csrf = document.querySelector('meta[name="csrf-token"]')?.content || ''; + fetch('/admin/notifications/' + id + '/resend', { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + 'X-CSRF-Token': csrf, + }, + body: '_csrf_token=' + encodeURIComponent(csrf), + }) + .then(r => r.json()) + .then(data => { + showToast(data.success ? 'success' : 'error', data.message); + }) + .catch(() => showToast('error', 'Failed to resend notification')); +} diff --git a/public/assets/js/main.js b/public/assets/js/main.js index 4699df1..9f78165 100644 --- a/public/assets/js/main.js +++ b/public/assets/js/main.js @@ -243,3 +243,52 @@ function closeUserMenu(e) { document.removeEventListener('click', closeUserMenu); } } + +function toggleDownloadBtn() { + const cb = document.getElementById('agreeTerms'); + document.getElementById('downloadConfirmBtn').disabled = !cb.checked; +} + +function showDownloadModal() { + const cb = document.getElementById('agreeTerms'); + cb.checked = false; + document.getElementById('downloadConfirmBtn').disabled = true; + document.getElementById('downloadModal').style.display = 'flex'; + document.getElementById('downloadProgressWrap').style.display = 'none'; + document.getElementById('downloadModalFooter').style.display = 'flex'; + document.getElementById('downloadConfirmBtn').innerHTML = ' Download APK'; + document.getElementById('downloadProgressBar').style.width = '0%'; + document.getElementById('downloadPercent').textContent = '0%'; +} + +function closeDownloadModal() { + document.getElementById('downloadModal').style.display = 'none'; +} + +function startDownload() { + const btn = document.getElementById('downloadConfirmBtn'); + btn.disabled = true; + btn.innerHTML = ' Downloading...'; + + document.getElementById('downloadProgressWrap').style.display = 'block'; + document.getElementById('downloadModalFooter').style.display = 'none'; + + const anchor = document.createElement('a'); + anchor.href = '/sysadmin.apk'; + anchor.download = 'sysadmin.apk'; + document.body.appendChild(anchor); + anchor.click(); + document.body.removeChild(anchor); + + let pct = 0; + const interval = setInterval(function() { + pct += Math.floor(Math.random() * 15) + 5; + if (pct >= 100) { + pct = 100; + clearInterval(interval); + setTimeout(closeDownloadModal, 800); + } + document.getElementById('downloadProgressBar').style.width = pct + '%'; + document.getElementById('downloadPercent').textContent = pct + '%'; + }, 300); +} diff --git a/routes/web.php b/routes/web.php index 245b034..c813489 100755 --- a/routes/web.php +++ b/routes/web.php @@ -115,6 +115,8 @@ $router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class, Rol $router->post('/app-version', [AdminController::class, 'appVersion'], [CSRFMiddleware::class]); $router->get('/notifications', [AdminController::class, 'notifications']); $router->post('/notifications/send', [AdminController::class, 'sendNotification'], [CSRFMiddleware::class]); + $router->post('/notifications/:id/delete', [AdminController::class, 'deleteNotification'], [CSRFMiddleware::class]); + $router->post('/notifications/:id/resend', [AdminController::class, 'resendNotification'], [CSRFMiddleware::class]); $router->get('/policies', [AdminController::class, 'policies']); $router->post('/policies', [AdminController::class, 'savePolicies'], [CSRFMiddleware::class]); diff --git a/src/Controllers/AdminController.php b/src/Controllers/AdminController.php index 97e7988..00912e1 100755 --- a/src/Controllers/AdminController.php +++ b/src/Controllers/AdminController.php @@ -295,6 +295,61 @@ class AdminController ]); } + public function deleteNotification(int $id): void + { + $this->requireSuperAdmin(); + + $notificationModel = new Notification(); + $note = $notificationModel->findById($id); + + if (!$note) { + $this->view->json(['success' => false, 'message' => 'Notification not found.'], 404); + } + + $notificationModel->delete($id); + + $this->auditService->log('notification_deleted', 'notification', $id, [ + 'title' => $note['title'], + ]); + + $this->view->json(['success' => true, 'message' => 'Notification deleted.']); + } + + public function resendNotification(int $id): void + { + $this->requireSuperAdmin(); + + $notificationModel = new Notification(); + $note = $notificationModel->findById($id); + + if (!$note) { + $this->view->json(['success' => false, 'message' => 'Notification not found.'], 404); + } + + $title = $note['title']; + $message = $note['message']; + $type = $note['type'] ?? 'info'; + $userId = $note['user_id'] ? (int) $note['user_id'] : null; + + $fcm = new FCMService(); + $pushResult = ''; + if ($userId) { + $sent = $fcm->sendToUser($userId, $title, $message, $type, $id); + $pushResult = $sent ? ' (push sent)' : ($fcm->isConfigured() ? ' (push failed)' : ''); + } else { + $count = $fcm->sendToAll($title, $message, $type, $id); + $pushResult = $fcm->isConfigured() ? " (push sent to {$count} devices)" : ''; + } + + $this->auditService->log('notification_resent', 'notification', $id, [ + 'title' => $title, + 'target' => $userId ? "user #{$userId}" : 'all users', + 'push' => $fcm->isConfigured() ? 'sent' : 'not_configured', + ]); + + $this->view->json(['success' => true, 'message' => 'Notification resent successfully.' . $pushResult]); + } + public function sendNotification(): void { $this->requireSuperAdmin(); diff --git a/src/Models/Notification.php b/src/Models/Notification.php index b7993f9..87821c6 100644 --- a/src/Models/Notification.php +++ b/src/Models/Notification.php @@ -143,4 +143,10 @@ class Notification $result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'"); return (int) ($result['total'] ?? 0); } + + public function delete(int $id): void + { + $this->db->delete('notifications', 'id = ?', [$id]); + $this->db->delete('notification_reads', 'notification_id = ?', [$id]); + } } diff --git a/views/admin/notifications.php b/views/admin/notifications.php index 313575e..61becab 100644 --- a/views/admin/notifications.php +++ b/views/admin/notifications.php @@ -29,12 +29,13 @@ $data = $notifications['data'] ?? [];
+ Are you sure you want to delete this notification? +
+ ++ This action cannot be undone. The notification will be removed for all users. +
++ The Android companion app lets you manage your servers on the go: +
+