fix: CSS sync, sidebar redesign, asset versioning, route/auth fixes, admin dashboard, notifications management #93
@@ -145,10 +145,26 @@ code, pre {
|
|||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar {
|
||||||
|
width: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar::-webkit-scrollbar-thumb {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:hover::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.collapsed {
|
.sidebar.collapsed {
|
||||||
width: var(--sidebar-collapsed);
|
width: var(--sidebar-collapsed);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar.collapsed .sidebar-header::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-header {
|
.sidebar-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -157,6 +173,18 @@ code, pre {
|
|||||||
border-bottom: 1px solid var(--border-color);
|
border-bottom: 1px solid var(--border-color);
|
||||||
height: var(--topbar-height);
|
height: var(--topbar-height);
|
||||||
min-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 {
|
.sidebar-brand {
|
||||||
@@ -167,6 +195,7 @@ code, pre {
|
|||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
font-size: 1.2rem;
|
font-size: 1.2rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-brand i {
|
.sidebar-brand i {
|
||||||
@@ -175,7 +204,8 @@ code, pre {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-item i,
|
.nav-item i,
|
||||||
.nav-sub-item i {
|
.nav-sub-item i,
|
||||||
|
.nav-item-toggle i {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -251,19 +281,22 @@ code, pre {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.75rem;
|
gap: 0.75rem;
|
||||||
padding: 0.65rem 1.25rem;
|
padding: 0.6rem 1.25rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
border-radius: 0;
|
border-radius: var(--radius-sm);
|
||||||
transition: all var(--transition);
|
transition: all var(--transition);
|
||||||
font-size: 0.92rem;
|
font-size: 0.9rem;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-left: 3px solid transparent;
|
text-decoration: none;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item i {
|
.nav-item i {
|
||||||
width: 20px;
|
width: 20px;
|
||||||
text-align: center;
|
text-align: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item:hover {
|
.nav-item:hover {
|
||||||
@@ -274,23 +307,35 @@ code, pre {
|
|||||||
.nav-item.active {
|
.nav-item.active {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
background: var(--accent-light);
|
background: var(--accent-light);
|
||||||
border-left-color: var(--accent);
|
font-weight: 600;
|
||||||
font-weight: 500;
|
}
|
||||||
|
|
||||||
|
.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 {
|
.nav-divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
background: var(--border-color);
|
background: var(--border-color);
|
||||||
margin: 0.5rem 1rem;
|
margin: 0.5rem 1rem;
|
||||||
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-section {
|
.nav-section {
|
||||||
padding: 0.5rem 1.25rem;
|
padding: 0.6rem 1.25rem 0.35rem;
|
||||||
font-size: 0.75rem;
|
font-size: 0.7rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.1em;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
font-weight: 600;
|
font-weight: 700;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +357,7 @@ code, pre {
|
|||||||
.sidebar.collapsed .nav-item {
|
.sidebar.collapsed .nav-item {
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
padding: 0.65rem;
|
padding: 0.65rem;
|
||||||
|
margin: 0 0.35rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ==========================================================================
|
/* ==========================================================================
|
||||||
@@ -670,6 +716,14 @@ code, pre {
|
|||||||
box-shadow: var(--shadow-sm);
|
box-shadow: var(--shadow-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn:disabled,
|
||||||
|
.btn.disabled {
|
||||||
|
opacity: 0.55;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
box-shadow: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
background: var(--accent);
|
background: var(--accent);
|
||||||
color: white;
|
color: white;
|
||||||
@@ -681,6 +735,26 @@ code, pre {
|
|||||||
color: white;
|
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 {
|
.btn-secondary {
|
||||||
background: var(--bg-tertiary);
|
background: var(--bg-tertiary);
|
||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
@@ -875,13 +949,45 @@ textarea.form-input {
|
|||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
position: relative;
|
||||||
|
padding-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.checkbox-label input[type="checkbox"] {
|
.checkbox-label input[type="checkbox"] {
|
||||||
width: 16px;
|
position: absolute;
|
||||||
height: 16px;
|
opacity: 0;
|
||||||
accent-color: var(--accent);
|
width: 0;
|
||||||
cursor: pointer;
|
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 {
|
.font-mono {
|
||||||
@@ -2340,6 +2446,7 @@ textarea.form-input {
|
|||||||
.sidebar.mobile-open.collapsed .nav-item {
|
.sidebar.mobile-open.collapsed .nav-item {
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
padding: 0.65rem 1.25rem;
|
padding: 0.65rem 1.25rem;
|
||||||
|
margin: 0 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-area {
|
.main-area {
|
||||||
@@ -2379,7 +2486,8 @@ textarea.form-input {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.nav-sublist {
|
.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;
|
gap: 0.5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-item-accordion.expanded > .nav-item-toggle {
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
.nav-chevron {
|
.nav-chevron {
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
font-size: 0.7rem;
|
font-size: 0.65rem;
|
||||||
transition: transform 0.2s;
|
transition: transform 0.25s ease;
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2765,18 +2877,22 @@ textarea.form-input {
|
|||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: max-height 0.25s ease;
|
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 {
|
.nav-sub-item {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 0.5rem;
|
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;
|
font-size: 0.82rem;
|
||||||
color: var(--text-secondary);
|
color: var(--text-secondary);
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
transition: background 0.1s;
|
transition: all 0.15s;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-sub-item:hover {
|
.nav-sub-item:hover {
|
||||||
@@ -2784,6 +2900,13 @@ textarea.form-input {
|
|||||||
color: var(--text-primary);
|
color: var(--text-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.nav-sub-item i {
|
||||||
|
width: 16px;
|
||||||
|
text-align: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.status-dot-sm {
|
.status-dot-sm {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
height: 6px;
|
height: 6px;
|
||||||
|
|||||||
@@ -38,3 +38,60 @@ function sendNotification() {
|
|||||||
btn.innerHTML = '<i class="fas fa-paper-plane"></i> Send';
|
btn.innerHTML = '<i class="fas fa-paper-plane"></i> 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 = '<i class="fas fa-spinner fa-spin"></i> 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'));
|
||||||
|
}
|
||||||
|
|||||||
@@ -243,3 +243,52 @@ function closeUserMenu(e) {
|
|||||||
document.removeEventListener('click', closeUserMenu);
|
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 = '<i class="fas fa-download"></i> 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 = '<i class="fas fa-spinner fa-spin"></i> 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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -115,6 +115,8 @@ $router->group(['prefix' => 'admin', 'middleware' => [AuthMiddleware::class, Rol
|
|||||||
$router->post('/app-version', [AdminController::class, 'appVersion'], [CSRFMiddleware::class]);
|
$router->post('/app-version', [AdminController::class, 'appVersion'], [CSRFMiddleware::class]);
|
||||||
$router->get('/notifications', [AdminController::class, 'notifications']);
|
$router->get('/notifications', [AdminController::class, 'notifications']);
|
||||||
$router->post('/notifications/send', [AdminController::class, 'sendNotification'], [CSRFMiddleware::class]);
|
$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->get('/policies', [AdminController::class, 'policies']);
|
||||||
$router->post('/policies', [AdminController::class, 'savePolicies'], [CSRFMiddleware::class]);
|
$router->post('/policies', [AdminController::class, 'savePolicies'], [CSRFMiddleware::class]);
|
||||||
|
|||||||
@@ -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
|
public function sendNotification(): void
|
||||||
{
|
{
|
||||||
$this->requireSuperAdmin();
|
$this->requireSuperAdmin();
|
||||||
|
|||||||
@@ -143,4 +143,10 @@ class Notification
|
|||||||
$result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'");
|
$result = $this->db->fetch("SELECT COUNT(*) as total FROM users WHERE status = 'active'");
|
||||||
return (int) ($result['total'] ?? 0);
|
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]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,12 +29,13 @@ $data = $notifications['data'] ?? [];
|
|||||||
<th>Message</th>
|
<th>Message</th>
|
||||||
<th>Target</th>
|
<th>Target</th>
|
||||||
<th>Read by</th>
|
<th>Read by</th>
|
||||||
|
<th style="width:60px">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<?php if (empty($data)): ?>
|
<?php if (empty($data)): ?>
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="6" class="text-center text-muted">No notifications sent yet.</td>
|
<td colspan="7" class="text-center text-muted">No notifications sent yet.</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php else: ?>
|
<?php else: ?>
|
||||||
<?php foreach ($data as $note): ?>
|
<?php foreach ($data as $note): ?>
|
||||||
@@ -65,6 +66,20 @@ $data = $notifications['data'] ?? [];
|
|||||||
</span>
|
</span>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
</td>
|
</td>
|
||||||
|
<td>
|
||||||
|
<div style="display:flex;gap:0.25rem">
|
||||||
|
<button class="btn btn-icon btn-xs" title="Resend"
|
||||||
|
onclick="confirmResend(<?= $note['id'] ?>)"
|
||||||
|
style="color:var(--info)">
|
||||||
|
<i class="fas fa-redo"></i>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-icon btn-xs" title="Delete"
|
||||||
|
onclick="showDeleteModal(<?= $note['id'] ?>, '<?= htmlspecialchars(addslashes($note['title'] ?? '')) ?>')"
|
||||||
|
style="color:var(--danger)">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<?php endforeach; ?>
|
<?php endforeach; ?>
|
||||||
<?php endif; ?>
|
<?php endif; ?>
|
||||||
@@ -139,4 +154,29 @@ $data = $notifications['data'] ?? [];
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Delete Confirmation Modal -->
|
||||||
|
<div class="modal" id="deleteModal" onclick="if (event.target === this) closeDeleteModal()">
|
||||||
|
<div class="modal-dialog" style="max-width: 400px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3><i class="fas fa-trash" style="color:var(--danger);margin-right:0.5rem"></i>Delete Notification</h3>
|
||||||
|
<button class="modal-close" onclick="closeDeleteModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="margin-bottom:0.5rem;font-size:0.92rem">
|
||||||
|
Are you sure you want to delete this notification?
|
||||||
|
</p>
|
||||||
|
<p id="deleteModalTitle" style="font-size:0.88rem;color:var(--text-muted);background:var(--bg-tertiary);padding:0.5rem 0.75rem;border-radius:var(--radius-sm)"></p>
|
||||||
|
<p style="margin-top:0.75rem;font-size:0.82rem;color:var(--text-muted)">
|
||||||
|
This action cannot be undone. The notification will be removed for all users.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-secondary" onclick="closeDeleteModal()">Cancel</button>
|
||||||
|
<button class="btn btn-danger" id="deleteConfirmBtn" onclick="executeDelete()">
|
||||||
|
<i class="fas fa-trash"></i> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/assets/js/admin-notifications.js?v=<?= asset_version() ?>"></script>
|
<script src="/assets/js/admin-notifications.js?v=<?= asset_version() ?>"></script>
|
||||||
|
|||||||
@@ -78,7 +78,7 @@ if (!isset($user) || $user === null) {
|
|||||||
<!-- Mobile -->
|
<!-- Mobile -->
|
||||||
<hr class="nav-divider">
|
<hr class="nav-divider">
|
||||||
<div class="nav-section">Mobile</div>
|
<div class="nav-section">Mobile</div>
|
||||||
<a href="/sysadmin.apk" class="nav-item" download>
|
<a href="#" class="nav-item" onclick="showDownloadModal(); return false;">
|
||||||
<i class="fas fa-download"></i>
|
<i class="fas fa-download"></i>
|
||||||
<span>Download APK</span>
|
<span>Download APK</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -168,6 +168,56 @@ if (!isset($user) || $user === null) {
|
|||||||
|
|
||||||
<div id="toastContainer" class="toast-container"></div>
|
<div id="toastContainer" class="toast-container"></div>
|
||||||
|
|
||||||
|
<!-- Download Modal -->
|
||||||
|
<div class="modal" id="downloadModal" onclick="if (event.target === this) closeDownloadModal()">
|
||||||
|
<div class="modal-dialog" style="max-width: 440px;">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h3><i class="fas fa-download" style="color:var(--accent);margin-right:0.5rem"></i>Download ServerManager</h3>
|
||||||
|
<button class="modal-close" onclick="closeDownloadModal()">×</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p style="margin-bottom:1rem;font-size:0.92rem;line-height:1.6">
|
||||||
|
The Android companion app lets you manage your servers on the go:
|
||||||
|
</p>
|
||||||
|
<ul style="list-style:none;padding:0;margin:0 0 1.25rem;font-size:0.88rem;display:flex;flex-direction:column;gap:0.6rem">
|
||||||
|
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Monitor server status and metrics</li>
|
||||||
|
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Receive push notifications for alerts</li>
|
||||||
|
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Execute commands remotely</li>
|
||||||
|
<li><i class="fas fa-check-circle" style="color:var(--success);width:20px;text-align:center;margin-right:0.5rem"></i> Manage services and processes</li>
|
||||||
|
</ul>
|
||||||
|
<div style="padding:0.75rem;background:var(--bg-tertiary);border-radius:var(--radius-sm);font-size:0.82rem;color:var(--text-muted);margin-bottom:1rem">
|
||||||
|
<strong style="color:var(--text-secondary);display:block;margin-bottom:0.4rem"><i class="fas fa-shield-alt" style="margin-right:0.35rem"></i>Required Permissions</strong>
|
||||||
|
<ul style="list-style:none;padding:0;margin:0;display:flex;flex-direction:column;gap:0.35rem">
|
||||||
|
<li><i class="fas fa-wifi" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Internet access for API communication</li>
|
||||||
|
<li><i class="fas fa-bell" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Push notifications for alerts</li>
|
||||||
|
<li><i class="fas fa-save" style="width:18px;text-align:center;margin-right:0.4rem;font-size:0.75rem"></i> Storage for offline data cache</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<label class="checkbox-label" style="margin-bottom:0.75rem;font-size:0.82rem">
|
||||||
|
<input type="checkbox" id="agreeTerms" onchange="toggleDownloadBtn()">
|
||||||
|
<span class="checkmark"><i class="fas fa-check"></i></span>
|
||||||
|
I agree to the <a href="/terms" target="_blank" style="color:var(--accent);text-decoration:underline">Terms & Conditions</a>
|
||||||
|
and <a href="/privacy" target="_blank" style="color:var(--accent);text-decoration:underline">Privacy Policy</a>
|
||||||
|
</label>
|
||||||
|
<div id="downloadProgressWrap" style="display:none">
|
||||||
|
<div style="display:flex;justify-content:space-between;font-size:0.8rem;color:var(--text-muted);margin-bottom:0.3rem">
|
||||||
|
<span>Downloading...</span>
|
||||||
|
<span id="downloadPercent">0%</span>
|
||||||
|
</div>
|
||||||
|
<div style="height:6px;background:var(--bg-tertiary);border-radius:3px;overflow:hidden">
|
||||||
|
<div id="downloadProgressBar" style="height:100%;width:0%;background:linear-gradient(90deg,var(--accent),var(--accent-hover));border-radius:3px;transition:width 0.3s ease"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer" id="downloadModalFooter">
|
||||||
|
<button class="btn btn-secondary" onclick="closeDownloadModal()">Cancel</button>
|
||||||
|
<button class="btn btn-primary" id="downloadConfirmBtn" onclick="startDownload()" disabled>
|
||||||
|
<i class="fas fa-download"></i> Download APK
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="/assets/js/app.js?v=<?= asset_version() ?>"></script>
|
<script src="/assets/js/app.js?v=<?= asset_version() ?>"></script>
|
||||||
<?php if (isset($scripts)): ?>
|
<?php if (isset($scripts)): ?>
|
||||||
<?php foreach ($scripts as $script): ?>
|
<?php foreach ($scripts as $script): ?>
|
||||||
|
|||||||
Reference in New Issue
Block a user