feat: Add comprehensive notification system with email support

- Create notification management system with VNotifications class
- Build notification API endpoints (get, mark read, delete, preferences)
- Implement notification bell UI widget with real-time polling
- Add email queue system with digest support (instant/hourly/daily/weekly)
- Create notification trigger helpers for common events
- Add fine-grained user preference controls
- Implement cron script for email processing and cleanup
- Document setup and usage in docs/NOTIFICATIONS.md

Features:
- In-app notifications with dropdown
- Email delivery with batching
- 6 notification types (comment, like, subscribe, upload, mention, system)
- User preference controls per notification type
- Lightweight polling (30s interval)
- Auto-cleanup of old notifications (90 days)
- Production-ready with proper error handling
This commit is contained in:
Krystie
2026-03-30 17:11:08 -07:00
parent 29c3c6fc8a
commit f50b493df2
9 changed files with 1671 additions and 0 deletions
+460
View File
@@ -0,0 +1,460 @@
/**
* EasyStream Notifications Widget
* Real-time notification bell with dropdown
*/
class NotificationWidget {
constructor(containerId = 'notification-widget') {
this.container = document.getElementById(containerId);
if (!this.container) {
console.error('Notification container not found');
return;
}
this.unreadCount = 0;
this.notifications = [];
this.dropdownOpen = false;
this.pollInterval = 30000; // Poll every 30 seconds
this.pollTimer = null;
this.init();
}
init() {
this.render();
this.attachEventListeners();
this.fetchNotifications();
this.startPolling();
}
render() {
this.container.innerHTML = `
<div class="notification-bell">
<button class="notification-btn" id="notificationBtn">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"></path>
<path d="M13.73 21a2 2 0 0 1-3.46 0"></path>
</svg>
<span class="notification-badge" id="notificationBadge" style="display: none;">0</span>
</button>
<div class="notification-dropdown" id="notificationDropdown" style="display: none;">
<div class="notification-header">
<h3>Notifications</h3>
<button class="mark-all-read-btn" id="markAllReadBtn">Mark all read</button>
</div>
<div class="notification-list" id="notificationList">
<div class="notification-loading">Loading...</div>
</div>
<div class="notification-footer">
<a href="/notifications">View all notifications</a>
</div>
</div>
</div>
`;
// Add styles
this.injectStyles();
}
injectStyles() {
if (document.getElementById('notification-widget-styles')) {
return;
}
const style = document.createElement('style');
style.id = 'notification-widget-styles';
style.textContent = `
.notification-bell {
position: relative;
display: inline-block;
}
.notification-btn {
background: none;
border: none;
cursor: pointer;
padding: 8px;
border-radius: 50%;
transition: background 0.2s;
position: relative;
}
.notification-btn:hover {
background: rgba(0,0,0,0.05);
}
.notification-badge {
position: absolute;
top: 4px;
right: 4px;
background: #dc3545;
color: white;
border-radius: 10px;
padding: 2px 6px;
font-size: 11px;
font-weight: bold;
min-width: 18px;
text-align: center;
}
.notification-dropdown {
position: absolute;
top: 100%;
right: 0;
margin-top: 8px;
width: 380px;
max-width: 95vw;
background: white;
border-radius: 12px;
box-shadow: 0 4px 24px rgba(0,0,0,0.15);
z-index: 1000;
max-height: 500px;
display: flex;
flex-direction: column;
}
.notification-header {
padding: 16px 20px;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.notification-header h3 {
margin: 0;
font-size: 18px;
font-weight: 600;
}
.mark-all-read-btn {
background: none;
border: none;
color: #007bff;
cursor: pointer;
font-size: 14px;
padding: 4px 8px;
}
.mark-all-read-btn:hover {
text-decoration: underline;
}
.notification-list {
overflow-y: auto;
max-height: 360px;
}
.notification-item {
padding: 16px 20px;
border-bottom: 1px solid #f0f0f0;
cursor: pointer;
transition: background 0.2s;
display: flex;
gap: 12px;
}
.notification-item:hover {
background: #f8f9fa;
}
.notification-item.unread {
background: #e7f3ff;
}
.notification-item.unread:hover {
background: #d0e9ff;
}
.notification-avatar {
width: 40px;
height: 40px;
border-radius: 50%;
background: #e9ecef;
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
}
.notification-content {
flex: 1;
min-width: 0;
}
.notification-title {
font-weight: 500;
margin: 0 0 4px 0;
font-size: 14px;
color: #212529;
}
.notification-message {
font-size: 13px;
color: #666;
margin: 0 0 4px 0;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.notification-time {
font-size: 12px;
color: #999;
}
.notification-loading, .notification-empty {
padding: 40px 20px;
text-align: center;
color: #666;
}
.notification-footer {
padding: 12px 20px;
border-top: 1px solid #e9ecef;
text-align: center;
}
.notification-footer a {
color: #007bff;
text-decoration: none;
font-size: 14px;
font-weight: 500;
}
.notification-footer a:hover {
text-decoration: underline;
}
`;
document.head.appendChild(style);
}
attachEventListeners() {
const btn = document.getElementById('notificationBtn');
const dropdown = document.getElementById('notificationDropdown');
const markAllBtn = document.getElementById('markAllReadBtn');
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleDropdown();
});
markAllBtn.addEventListener('click', () => {
this.markAllAsRead();
});
// Close dropdown when clicking outside
document.addEventListener('click', (e) => {
if (!this.container.contains(e.target)) {
this.closeDropdown();
}
});
}
toggleDropdown() {
if (this.dropdownOpen) {
this.closeDropdown();
} else {
this.openDropdown();
}
}
openDropdown() {
document.getElementById('notificationDropdown').style.display = 'block';
this.dropdownOpen = true;
this.fetchNotifications();
}
closeDropdown() {
document.getElementById('notificationDropdown').style.display = 'none';
this.dropdownOpen = false;
}
async fetchNotifications() {
try {
const response = await fetch('/api/notifications.php?limit=20');
const data = await response.json();
if (data.success) {
this.notifications = data.data.notifications;
this.unreadCount = data.data.unread_count;
this.updateBadge();
this.renderNotifications();
}
} catch (error) {
console.error('Failed to fetch notifications:', error);
}
}
async fetchUnreadCount() {
try {
const response = await fetch('/api/notifications_count.php');
const data = await response.json();
if (data.success) {
this.unreadCount = data.unread_count;
this.updateBadge();
}
} catch (error) {
console.error('Failed to fetch unread count:', error);
}
}
updateBadge() {
const badge = document.getElementById('notificationBadge');
if (this.unreadCount > 0) {
badge.textContent = this.unreadCount > 99 ? '99+' : this.unreadCount;
badge.style.display = 'block';
} else {
badge.style.display = 'none';
}
}
renderNotifications() {
const list = document.getElementById('notificationList');
if (this.notifications.length === 0) {
list.innerHTML = '<div class="notification-empty">No notifications</div>';
return;
}
list.innerHTML = this.notifications.map(n => this.renderNotification(n)).join('');
// Attach click handlers
list.querySelectorAll('.notification-item').forEach((item, index) => {
item.addEventListener('click', () => {
this.handleNotificationClick(this.notifications[index]);
});
});
}
renderNotification(notification) {
const isUnread = !notification.is_read;
const icon = this.getNotificationIcon(notification.type);
const timeAgo = this.formatTimeAgo(notification.created_at);
return `
<div class="notification-item ${isUnread ? 'unread' : ''}" data-id="${notification.notification_id}">
<div class="notification-avatar">${icon}</div>
<div class="notification-content">
<div class="notification-title">${notification.title}</div>
${notification.message ? `<div class="notification-message">${notification.message}</div>` : ''}
<div class="notification-time">${timeAgo}</div>
</div>
</div>
`;
}
getNotificationIcon(type) {
const icons = {
'comment': '💬',
'like': '❤️',
'subscribe': '🔔',
'video_upload': '🎬',
'mention': '👤',
'system': '️'
};
return icons[type] || '🔔';
}
formatTimeAgo(timestamp) {
const now = new Date();
const then = new Date(timestamp);
const seconds = Math.floor((now - then) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + 'd ago';
return Math.floor(seconds / 604800) + 'w ago';
}
async handleNotificationClick(notification) {
// Mark as read
await this.markAsRead(notification.notification_id);
// Navigate to link
if (notification.link) {
window.location.href = notification.link;
}
this.closeDropdown();
}
async markAsRead(id) {
try {
const response = await fetch('/api/notifications.php?action=mark_read', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ id })
});
const data = await response.json();
if (data.success) {
this.unreadCount = data.unread_count;
this.updateBadge();
// Update UI
const item = document.querySelector(`.notification-item[data-id="${id}"]`);
if (item) {
item.classList.remove('unread');
}
}
} catch (error) {
console.error('Failed to mark as read:', error);
}
}
async markAllAsRead() {
try {
const response = await fetch('/api/notifications.php?action=mark_all_read', {
method: 'POST'
});
const data = await response.json();
if (data.success) {
this.unreadCount = 0;
this.updateBadge();
this.fetchNotifications();
}
} catch (error) {
console.error('Failed to mark all as read:', error);
}
}
startPolling() {
this.pollTimer = setInterval(() => {
if (!this.dropdownOpen) {
this.fetchUnreadCount();
}
}, this.pollInterval);
}
stopPolling() {
if (this.pollTimer) {
clearInterval(this.pollTimer);
this.pollTimer = null;
}
}
destroy() {
this.stopPolling();
this.container.innerHTML = '';
}
}
// Auto-initialize if container exists
document.addEventListener('DOMContentLoaded', () => {
if (document.getElementById('notification-widget')) {
window.notificationWidget = new NotificationWidget();
}
});