Skip to content
This repository was archived by the owner on Jul 28, 2026. It is now read-only.

Commit f0e1be1

Browse files
committed
feat: add persistent notification system with sidebar panel, DB storage, and auto-creation on server events
1 parent db69d9f commit f0e1be1

10 files changed

Lines changed: 475 additions & 52 deletions

File tree

config/migrate.js

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,18 @@ const tables = {
7171
{ name: 'updated_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP' },
7272
],
7373
},
74+
notifications: {
75+
columns: [
76+
{ name: 'id', def: 'INT AUTO_INCREMENT PRIMARY KEY' },
77+
{ name: 'user_id', def: 'INT NOT NULL' },
78+
{ name: 'title', def: 'VARCHAR(255) NOT NULL' },
79+
{ name: 'message', def: 'TEXT NOT NULL' },
80+
{ name: 'type', def: "VARCHAR(20) NOT NULL DEFAULT 'info'" },
81+
{ name: 'link', def: 'VARCHAR(255) DEFAULT NULL' },
82+
{ name: 'is_read', def: 'TINYINT(1) NOT NULL DEFAULT 0' },
83+
{ name: 'created_at', def: 'TIMESTAMP DEFAULT CURRENT_TIMESTAMP' },
84+
],
85+
},
7486

7587
};
7688

@@ -92,6 +104,9 @@ const constraints = [
92104
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_user (user_id)', name: 'idx_activity_user' },
93105
{ table: 'activity_log', sql: 'ALTER TABLE activity_log ADD INDEX idx_activity_created (created_at)', name: 'idx_activity_created' },
94106
{ table: 'egg_resources', sql: 'ALTER TABLE egg_resources ADD UNIQUE INDEX idx_egg_resources_nest_egg (ptero_nest_id, ptero_egg_id)', name: 'idx_egg_resources_nest_egg' },
107+
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user (user_id)', name: 'idx_notif_user' },
108+
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD INDEX idx_notif_user_read (user_id, is_read)', name: 'idx_notif_user_read' },
109+
{ table: 'notifications', sql: 'ALTER TABLE notifications ADD CONSTRAINT fk_notif_user FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE', name: 'fk_notif_user' },
95110
];
96111

97112
export async function migrate() {

public/css/style.css

Lines changed: 186 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1225,34 +1225,203 @@ tbody tr:hover {
12251225
color: var(--accent-red);
12261226
}
12271227

1228-
.toast-info {
1229-
background: rgba(6, 182, 212, 0.15);
1230-
border: 1px solid rgba(6, 182, 212, 0.3);
1231-
color: var(--accent-cyan);
1228+
@keyframes slideUp {
1229+
from { opacity: 0; transform: translateY(20px); }
1230+
to { opacity: 1; transform: translateY(0); }
12321231
}
12331232

1234-
.toast-warning {
1235-
background: rgba(245, 158, 11, 0.15);
1236-
border: 1px solid rgba(245, 158, 11, 0.3);
1237-
color: var(--accent-orange);
1233+
/* ===== NOTIFICATION PANEL ===== */
1234+
.notif-panel {
1235+
position: fixed;
1236+
top: 0;
1237+
left: var(--sidebar-w);
1238+
width: 380px;
1239+
height: 100vh;
1240+
background: var(--bg-card);
1241+
border-right: 1px solid var(--border);
1242+
z-index: 99;
1243+
transform: translateX(-100%);
1244+
transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1);
1245+
display: flex;
1246+
flex-direction: column;
1247+
overflow: hidden;
12381248
}
12391249

1240-
.toast-icon {
1241-
width: 18px;
1242-
height: 18px;
1243-
flex-shrink: 0;
1250+
.notif-panel.open {
1251+
transform: translateX(0);
12441252
}
12451253

1246-
.toast {
1254+
.notif-backdrop {
1255+
position: fixed;
1256+
inset: 0;
1257+
z-index: 50;
1258+
background: rgba(0, 0, 0, 0.5);
1259+
backdrop-filter: blur(4px);
1260+
-webkit-backdrop-filter: blur(4px);
1261+
opacity: 0;
1262+
pointer-events: none;
1263+
transition: opacity 0.3s ease;
1264+
}
1265+
1266+
.notif-backdrop.open {
1267+
opacity: 1;
1268+
pointer-events: auto;
1269+
}
1270+
1271+
.notif-panel-header {
12471272
display: flex;
12481273
align-items: center;
1249-
gap: 10px;
1274+
justify-content: space-between;
1275+
padding: 20px 20px 16px;
1276+
border-bottom: 1px solid var(--border);
1277+
flex-shrink: 0;
1278+
}
1279+
1280+
.notif-panel-header h3 {
1281+
font-size: 1.1rem;
1282+
font-weight: 700;
1283+
color: var(--text-primary);
1284+
}
1285+
1286+
.notif-mark-all {
1287+
background: none;
1288+
border: none;
1289+
color: var(--accent-3);
1290+
font-size: 0.82rem;
1291+
font-weight: 600;
12501292
cursor: pointer;
1293+
padding: 4px 8px;
1294+
border-radius: var(--radius-sm);
1295+
transition: all var(--transition);
12511296
}
12521297

1253-
@keyframes slideUp {
1254-
from { opacity: 0; transform: translateY(20px); }
1255-
to { opacity: 1; transform: translateY(0); }
1298+
.notif-mark-all:hover {
1299+
background: rgba(238, 129, 50, 0.1);
1300+
}
1301+
1302+
.notif-panel-list {
1303+
flex: 1;
1304+
overflow-y: auto;
1305+
padding: 8px 0;
1306+
}
1307+
1308+
.notif-empty {
1309+
text-align: center;
1310+
color: var(--text-muted);
1311+
padding: 48px 20px;
1312+
font-size: 0.9rem;
1313+
}
1314+
1315+
.notif-item {
1316+
display: flex;
1317+
align-items: flex-start;
1318+
gap: 12px;
1319+
padding: 14px 20px;
1320+
cursor: pointer;
1321+
transition: background var(--transition);
1322+
position: relative;
1323+
}
1324+
1325+
.notif-item:hover {
1326+
background: rgba(238, 129, 50, 0.04);
1327+
}
1328+
1329+
.notif-unread {
1330+
background: rgba(238, 129, 50, 0.06);
1331+
}
1332+
1333+
.notif-unread:hover {
1334+
background: rgba(238, 129, 50, 0.1);
1335+
}
1336+
1337+
.notif-item-icon {
1338+
width: 36px;
1339+
height: 36px;
1340+
border-radius: 50%;
1341+
display: flex;
1342+
align-items: center;
1343+
justify-content: center;
1344+
flex-shrink: 0;
1345+
}
1346+
1347+
.notif-icon {
1348+
width: 18px;
1349+
height: 18px;
1350+
}
1351+
1352+
.notif-item-icon.notif-success {
1353+
background: rgba(16, 185, 129, 0.15);
1354+
color: var(--accent-green);
1355+
}
1356+
1357+
.notif-item-icon.notif-error {
1358+
background: rgba(239, 68, 68, 0.15);
1359+
color: var(--accent-red);
1360+
}
1361+
1362+
.notif-item-icon.notif-warning {
1363+
background: rgba(245, 158, 11, 0.15);
1364+
color: var(--accent-orange);
1365+
}
1366+
1367+
.notif-item-icon.notif-info {
1368+
background: rgba(6, 182, 212, 0.15);
1369+
color: var(--accent-cyan);
1370+
}
1371+
1372+
.notif-item-body {
1373+
flex: 1;
1374+
min-width: 0;
1375+
}
1376+
1377+
.notif-item-title {
1378+
font-size: 0.88rem;
1379+
font-weight: 600;
1380+
color: var(--text-primary);
1381+
margin-bottom: 2px;
1382+
}
1383+
1384+
.notif-item-msg {
1385+
font-size: 0.82rem;
1386+
color: var(--text-secondary);
1387+
line-height: 1.4;
1388+
display: -webkit-box;
1389+
-webkit-line-clamp: 2;
1390+
-webkit-box-orient: vertical;
1391+
overflow: hidden;
1392+
}
1393+
1394+
.notif-item-time {
1395+
font-size: 0.75rem;
1396+
color: var(--text-muted);
1397+
margin-top: 4px;
1398+
}
1399+
1400+
.notif-dot {
1401+
width: 8px;
1402+
height: 8px;
1403+
border-radius: 50%;
1404+
background: var(--accent-3);
1405+
flex-shrink: 0;
1406+
margin-top: 6px;
1407+
}
1408+
1409+
.notif-badge {
1410+
position: absolute;
1411+
top: 6px;
1412+
right: 8px;
1413+
min-width: 18px;
1414+
height: 18px;
1415+
border-radius: 99px;
1416+
background: var(--accent-red);
1417+
color: #fff;
1418+
font-size: 0.65rem;
1419+
font-weight: 700;
1420+
display: none;
1421+
align-items: center;
1422+
justify-content: center;
1423+
padding: 0 5px;
1424+
line-height: 1;
12561425
}
12571426

12581427
/* ===== RESPONSIVE ===== */

public/js/admin.js

Lines changed: 0 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,30 +44,6 @@ async function adminApi(path, options = {}) {
4444

4545
function $a(sel) { return document.querySelector(sel); }
4646

47-
const ADMIN_TOAST_ICONS = {
48-
success: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg>',
49-
error: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M15 9l-6 6M9 9l6 6"/></svg>',
50-
info: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="M12 16v-4M12 8h.01"/></svg>',
51-
warning: '<svg class="toast-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><path d="M12 9v4"/><path d="M12 17h.01"/></svg>',
52-
};
53-
54-
function showAdminToast(message, type = 'success') {
55-
const container = $a('#admin-toast-container') || (() => {
56-
const el = document.createElement('div');
57-
el.id = 'admin-toast-container';
58-
el.className = 'toast-container';
59-
document.body.appendChild(el);
60-
return el;
61-
})();
62-
63-
const toast = document.createElement('div');
64-
toast.className = `toast toast-${type}`;
65-
toast.innerHTML = (ADMIN_TOAST_ICONS[type] || ADMIN_TOAST_ICONS.success) + `<span>${message}</span>`;
66-
toast.addEventListener('click', () => toast.remove());
67-
container.appendChild(toast);
68-
setTimeout(() => { if (toast.parentNode) toast.remove(); }, 4000);
69-
}
70-
7147
function adminNavigateTo(page) {
7248
const parts = page.split('/');
7349
const basePage = parts[0] || 'servers';

0 commit comments

Comments
 (0)