feat: Add notifications bell with GBAlerts infrastructure for all apps

- Added bell icon in header toolbar with badge counter
- Created GBAlerts global API for notifications from any app
- App-specific shortcuts: taskCompleted, newEmail, newChat, driveSync, calendarReminder
- Integrated task completion with bell notifications
- Added CSS for notifications panel and items
- Documented in PROMPT.md Design System section
This commit is contained in:
Rodrigo Rodriguez (Pragmatismo) 2026-01-02 14:08:44 -03:00
parent 0f658fd7c5
commit 511cc24e4d
4 changed files with 478 additions and 0 deletions

View file

@ -877,6 +877,35 @@ When creating a new screen, ensure it has:
- [ ] Fixed terminal/status panels at bottom - [ ] Fixed terminal/status panels at bottom
- [ ] Variable content area with internal scroll - [ ] Variable content area with internal scroll
### Alert Infrastructure (Bell Notifications)
Use `window.GBAlerts` for app notifications that appear in the global bell icon:
```javascript
// Task completed (with optional app URL)
window.GBAlerts.taskCompleted("My App", "/apps/my-app/");
// Email notification
window.GBAlerts.newEmail("john@example.com", "Meeting tomorrow");
// Chat message
window.GBAlerts.newChat("John", "Hey, are you there?");
// Drive sync
window.GBAlerts.driveSync("report.pdf", "uploaded");
// Calendar reminder
window.GBAlerts.calendarReminder("Team Meeting", "15 minutes");
// Error
window.GBAlerts.error("Drive", "Failed to sync file");
// Generic notification
window.GBAlerts.add("Title", "Message", "success", "🎉");
```
All notifications appear in the bell dropdown with sound (if enabled).
--- ---
## Remember ## Remember

View file

@ -333,6 +333,62 @@
</nav> </nav>
</div> </div>
<!-- Notifications Bell -->
<div class="notifications-menu">
<button
class="header-btn notifications-btn"
id="notifications-btn"
aria-label="Notifications"
aria-expanded="false"
onclick="toggleNotificationsPanel()"
>
<svg
width="20"
height="20"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path
d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"
/>
<path d="M13.73 21a2 2 0 0 1-3.46 0" />
</svg>
<span
class="notifications-badge"
id="notifications-badge"
style="display: none"
>0</span
>
</button>
<div
class="notifications-panel"
id="notifications-panel"
role="menu"
>
<div class="notifications-panel-header">
<span class="notifications-panel-title"
>Notifications</span
>
<button
class="notifications-clear-btn"
onclick="clearAllNotifications()"
>
Clear all
</button>
</div>
<div class="notifications-list" id="notifications-list">
<div class="notifications-empty">
<span>🔔</span>
<p>No new notifications</p>
</div>
</div>
</div>
</div>
<!-- Settings Panel (Gear Icon) --> <!-- Settings Panel (Gear Icon) -->
<div class="settings-menu"> <div class="settings-menu">
<button <button
@ -933,6 +989,192 @@
} }
}; };
// ========== NOTIFICATIONS BELL INFRASTRUCTURE ==========
// Global alert system - use from any app (drive, chat, email, tasks, etc.)
window.GBAlerts = {
notifications: [],
sound: new Audio('/assets/sounds/notification.mp3'),
// Add a notification to the bell
add: function(title, message, type = 'info', appIcon = '🔔') {
const notification = {
id: Date.now(),
title: title,
message: message,
type: type,
appIcon: appIcon,
timestamp: new Date(),
read: false
};
this.notifications.unshift(notification);
this.updateBadge();
this.renderNotifications();
this.playSound();
// Also show toast notification
window.showNotification(`${appIcon} ${title}: ${message}`, type, 5000);
return notification.id;
},
// App-specific shortcuts
taskCompleted: function(taskName, appUrl) {
this.add('Task Completed', `"${taskName}" is ready`, 'success', '✅');
if (appUrl) {
// Store app URL for quick access
this.notifications[0].actionUrl = appUrl;
this.notifications[0].actionLabel = 'Open App';
}
},
newEmail: function(from, subject) {
this.add('New Email', `From ${from}: ${subject}`, 'info', '📧');
},
newChat: function(from, preview) {
this.add('New Message', `${from}: ${preview}`, 'info', '💬');
},
driveSync: function(filename, action) {
this.add('Drive', `${filename} ${action}`, 'info', '📁');
},
calendarReminder: function(event, time) {
this.add('Reminder', `${event} in ${time}`, 'warning', '📅');
},
error: function(source, message) {
this.add(`Error in ${source}`, message, 'error', '⚠️');
},
// Update badge count
updateBadge: function() {
const badge = document.getElementById('notifications-badge');
const unreadCount = this.notifications.filter(n => !n.read).length;
if (badge) {
badge.textContent = unreadCount > 99 ? '99+' : unreadCount;
badge.style.display = unreadCount > 0 ? 'flex' : 'none';
}
},
// Render notifications list
renderNotifications: function() {
const list = document.getElementById('notifications-list');
if (!list) return;
if (this.notifications.length === 0) {
list.innerHTML = `
<div class="notifications-empty">
<span>🔔</span>
<p>No new notifications</p>
</div>
`;
return;
}
list.innerHTML = this.notifications.slice(0, 20).map(n => `
<div class="notification-item ${n.read ? 'read' : 'unread'} ${n.type}" data-id="${n.id}" onclick="GBAlerts.markAsRead(${n.id})">
<div class="notification-item-icon">${n.appIcon}</div>
<div class="notification-item-content">
<div class="notification-item-title">${n.title}</div>
<div class="notification-item-message">${n.message}</div>
<div class="notification-item-time">${this.formatTime(n.timestamp)}</div>
</div>
${n.actionUrl ? `<a href="${n.actionUrl}" class="notification-item-action" onclick="event.stopPropagation()">${n.actionLabel || 'Open'}</a>` : ''}
<button class="notification-item-dismiss" onclick="event.stopPropagation(); GBAlerts.remove(${n.id})">×</button>
</div>
`).join('');
},
// Format timestamp
formatTime: function(date) {
const now = new Date();
const diff = now - date;
if (diff < 60000) return 'Just now';
if (diff < 3600000) return `${Math.floor(diff / 60000)}m ago`;
if (diff < 86400000) return `${Math.floor(diff / 3600000)}h ago`;
return date.toLocaleDateString();
},
// Mark as read
markAsRead: function(id) {
const notification = this.notifications.find(n => n.id === id);
if (notification) {
notification.read = true;
this.updateBadge();
this.renderNotifications();
if (notification.actionUrl) {
window.location.href = notification.actionUrl;
}
}
},
// Remove notification
remove: function(id) {
this.notifications = this.notifications.filter(n => n.id !== id);
this.updateBadge();
this.renderNotifications();
},
// Clear all
clearAll: function() {
this.notifications = [];
this.updateBadge();
this.renderNotifications();
},
// Play notification sound
playSound: function() {
const soundEnabled = localStorage.getItem('gb-sound') !== 'false';
if (soundEnabled && this.sound) {
this.sound.volume = 0.3;
this.sound.play().catch(() => {});
}
}
};
// Toggle notifications panel
function toggleNotificationsPanel() {
const panel = document.getElementById('notifications-panel');
const btn = document.getElementById('notifications-btn');
if (panel.classList.contains('show')) {
panel.classList.remove('show');
btn.setAttribute('aria-expanded', 'false');
} else {
// Close other panels first
document.getElementById('settings-panel')?.classList.remove('show');
document.getElementById('apps-dropdown')?.classList.remove('show');
panel.classList.add('show');
btn.setAttribute('aria-expanded', 'true');
// Render latest notifications
window.GBAlerts.renderNotifications();
}
}
function clearAllNotifications() {
window.GBAlerts.clearAll();
}
// Close notifications panel when clicking outside
document.addEventListener('click', function(e) {
const panel = document.getElementById('notifications-panel');
const btn = document.getElementById('notifications-btn');
if (panel && !panel.contains(e.target) && !btn.contains(e.target)) {
panel.classList.remove('show');
btn?.setAttribute('aria-expanded', 'false');
}
});
};
// Global HTMX error handling with retry mechanism // Global HTMX error handling with retry mechanism
const htmxRetryConfig = { const htmxRetryConfig = {
maxRetries: 3, maxRetries: 3,

View file

@ -919,6 +919,208 @@ body {
} }
} }
/* ============================================ */
/* NOTIFICATIONS BELL & PANEL */
/* ============================================ */
.notifications-menu {
position: relative;
}
.notifications-btn {
position: relative;
}
.notifications-badge {
position: absolute;
top: -4px;
right: -4px;
min-width: 18px;
height: 18px;
padding: 0 5px;
background: var(--error, #ef4444);
color: white;
font-size: 10px;
font-weight: 700;
border-radius: 9px;
display: flex;
align-items: center;
justify-content: center;
}
.notifications-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 360px;
max-height: 480px;
background: var(--surface, #161616);
border: 1px solid var(--border, #2a2a2a);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
display: none;
flex-direction: column;
z-index: 1000;
overflow: hidden;
}
.notifications-panel.show {
display: flex;
}
.notifications-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px;
border-bottom: 1px solid var(--border, #2a2a2a);
}
.notifications-panel-title {
font-size: 14px;
font-weight: 600;
color: var(--text, #fff);
}
.notifications-clear-btn {
font-size: 12px;
color: var(--text-secondary, #888);
background: none;
border: none;
cursor: pointer;
padding: 4px 8px;
border-radius: 4px;
transition: all 0.2s;
}
.notifications-clear-btn:hover {
background: var(--surface-hover, #1e1e1e);
color: var(--primary, #c5f82a);
}
.notifications-list {
flex: 1;
overflow-y: auto;
max-height: 400px;
}
.notifications-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 20px;
color: var(--text-secondary, #888);
}
.notifications-empty span {
font-size: 32px;
margin-bottom: 12px;
opacity: 0.5;
}
.notifications-empty p {
font-size: 13px;
margin: 0;
}
.notification-item {
display: flex;
align-items: flex-start;
gap: 12px;
padding: 12px 16px;
border-bottom: 1px solid var(--border, #2a2a2a);
cursor: pointer;
transition: background 0.2s;
}
.notification-item:hover {
background: var(--surface-hover, #1e1e1e);
}
.notification-item.unread {
background: rgba(197, 248, 42, 0.05);
}
.notification-item.success .notification-item-icon {
color: var(--success, #22c55e);
}
.notification-item.error .notification-item-icon {
color: var(--error, #ef4444);
}
.notification-item.warning .notification-item-icon {
color: var(--warning, #f59e0b);
}
.notification-item-icon {
font-size: 20px;
flex-shrink: 0;
width: 28px;
text-align: center;
}
.notification-item-content {
flex: 1;
min-width: 0;
}
.notification-item-title {
font-size: 13px;
font-weight: 600;
color: var(--text, #fff);
margin-bottom: 2px;
}
.notification-item-message {
font-size: 12px;
color: var(--text-secondary, #888);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.notification-item-time {
font-size: 11px;
color: var(--text-tertiary, #666);
margin-top: 4px;
}
.notification-item-action {
font-size: 11px;
color: var(--primary, #c5f82a);
text-decoration: none;
padding: 4px 8px;
border-radius: 4px;
background: rgba(197, 248, 42, 0.1);
white-space: nowrap;
}
.notification-item-action:hover {
background: rgba(197, 248, 42, 0.2);
}
.notification-item-dismiss {
font-size: 16px;
color: var(--text-tertiary, #666);
background: none;
border: none;
cursor: pointer;
padding: 4px;
line-height: 1;
opacity: 0;
transition: opacity 0.2s;
}
.notification-item:hover .notification-item-dismiss {
opacity: 1;
}
.notification-item-dismiss:hover {
color: var(--error, #ef4444);
}
/* ============================================ */ /* ============================================ */
/* PRINT STYLES */ /* PRINT STYLES */
/* ============================================ */ /* ============================================ */

View file

@ -2526,6 +2526,11 @@ function onTaskCompleted(data, appUrl) {
const title = data.title || data.message || "Task"; const title = data.title || data.message || "Task";
const taskId = data.task_id || data.id; const taskId = data.task_id || data.id;
// Add to bell notifications using global GBAlerts infrastructure
if (window.GBAlerts) {
window.GBAlerts.taskCompleted(title, appUrl);
}
if (appUrl) { if (appUrl) {
showToast(`App ready! Click to open: ${appUrl}`, "success", 10000, () => { showToast(`App ready! Click to open: ${appUrl}`, "success", 10000, () => {
window.open(appUrl, "_blank"); window.open(appUrl, "_blank");