Files
easystream/f_scripts/fe/js/notifications.js
T
Krystie f50b493df2 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
2026-03-30 17:11:08 -07:00

461 lines
14 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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();
}
});