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:
@@ -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();
|
||||
}
|
||||
});
|
||||
Reference in New Issue
Block a user