f50b493df2
- 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
461 lines
14 KiB
JavaScript
461 lines
14 KiB
JavaScript
/**
|
||
* 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();
|
||
}
|
||
});
|