324 lines
16 KiB
JavaScript
324 lines
16 KiB
JavaScript
import { Controller } from '@hotwired/stimulus';
|
||
|
||
export default class extends Controller {
|
||
static targets = ['badge', 'list'];
|
||
static values = {
|
||
userId: Number,
|
||
mercureUrl: String
|
||
};
|
||
|
||
connect() {
|
||
this.loadNotifications();
|
||
this.connectToMercure();
|
||
this.toastContainer = this.createToastContainer();
|
||
}
|
||
|
||
disconnect() {
|
||
if (this.eventSource) {
|
||
this.eventSource.close();
|
||
}
|
||
}
|
||
|
||
async loadNotifications() {
|
||
try {
|
||
const response = await fetch('/notifications/unread');
|
||
const data = await response.json();
|
||
|
||
this.updateBadge(data.unreadCount);
|
||
this.renderNotifications(data.notifications);
|
||
} catch (error) {
|
||
console.error('Failed to load notifications:', error);
|
||
}
|
||
}
|
||
|
||
async connectToMercure() {
|
||
try {
|
||
// Fetch the JWT token and topic from the server
|
||
const response = await fetch('/mercure-token');
|
||
const data = await response.json();
|
||
|
||
|
||
// Use server-provided topic if available, otherwise fallback to default per-user topic
|
||
const topic = data.topic || `http://portail.solutions-easy.moi/notifications/user/${this.userIdValue}`;
|
||
const url = new URL(this.mercureUrlValue);
|
||
url.searchParams.append('topic', topic);
|
||
|
||
// Add authorization token as URL param if provided (Mercure can accept it this way)
|
||
if (data.token) {
|
||
url.searchParams.append('authorization', data.token);
|
||
}
|
||
|
||
try {
|
||
this.eventSource = new EventSource(url.toString());
|
||
} catch (e) {
|
||
console.error('❌ Failed to create EventSource:', e);
|
||
return;
|
||
}
|
||
|
||
this.eventSource.onopen = () => {
|
||
};
|
||
|
||
this.eventSource.onmessage = (event) => {
|
||
try {
|
||
const notification = JSON.parse(event.data);
|
||
this.handleNewNotification(notification);
|
||
} catch (parseError) {
|
||
console.error('Failed to parse Mercure message data:', parseError, 'raw data:', event.data);
|
||
}
|
||
};
|
||
|
||
this.eventSource.onerror = (error) => {
|
||
try {
|
||
console.error('EventSource readyState:', this.eventSource.readyState);
|
||
} catch (e) {
|
||
console.error('Could not read EventSource.readyState:', e);
|
||
}
|
||
|
||
// EventSource will automatically try to reconnect.
|
||
// If closed, log it for debugging.
|
||
try {
|
||
if (this.eventSource.readyState === EventSource.CLOSED) {
|
||
console.log();
|
||
} else if (this.eventSource.readyState === EventSource.CONNECTING) {
|
||
console.log();
|
||
} else if (this.eventSource.readyState === EventSource.OPEN) {
|
||
console.log();
|
||
}
|
||
} catch (e) {
|
||
console.error('Error while checking EventSource state:', e);
|
||
}
|
||
};
|
||
} catch (error) {
|
||
console.error('Failed to connect to Mercure:', error);
|
||
}
|
||
}
|
||
|
||
handleNewNotification(notification) {
|
||
this.showToast(notification);
|
||
this.loadNotifications();
|
||
}
|
||
|
||
updateBadge(count) {
|
||
const badge = this.badgeTarget;
|
||
if (count > 0) {
|
||
badge.textContent = count > 99 ? '99+' : count;
|
||
badge.style.display = 'block';
|
||
} else {
|
||
badge.style.display = 'none';
|
||
}
|
||
}
|
||
|
||
renderNotifications(notifications) {
|
||
const list = this.listTarget;
|
||
|
||
if (notifications.length === 0) {
|
||
list.innerHTML = `
|
||
<div class="text-center py-4 text-muted">
|
||
<i class="mx-0 mb-2">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="30" height="30" fill="currentColor" viewBox="0 0 16 16">
|
||
<path d="M8.865 2.5a.75.75 0 0 0-1.73 0L6.5 5.5H4.75a.75.75 0 0 0 0 1.5h1.5l-.5 3H3.5a.75.75 0 0 0 0 1.5h1.5l-.635 3.135a.75.75 0 0 0 1.47.28L6.5 11.5h3l-.635 3.135a.75.75 0 0 0 1.47.28L11 11.5h1.75a.75.75 0 0 0 0-1.5h-1.5l.5-3h2.25a.75.75 0 0 0 0-1.5h-2l.635-3.135zM9.5 10l.5-3h-3l-.5 3h3z"/>
|
||
</svg>
|
||
</i>
|
||
<p class="mb-0">Aucune notification</p>
|
||
</div>
|
||
`;
|
||
return;
|
||
}
|
||
|
||
list.innerHTML = notifications.map(notif => this.renderNotificationItem(notif)).join('');
|
||
}
|
||
|
||
renderNotificationItem(notification) {
|
||
const iconHtml = this.getIcon(notification.type);
|
||
const timeAgo = this.getTimeAgo(notification.createdAt);
|
||
const readClass = notification.isRead ? 'opacity-75' : '';
|
||
|
||
return `
|
||
<a class="dropdown-item preview-item ${readClass}"
|
||
href="#"
|
||
data-notification-id="${notification.id}"
|
||
data-action="click->notification#markAsRead">
|
||
<div class="preview-thumbnail">
|
||
<div class="preview-icon ${this.getIconBgClass(notification.type)}">
|
||
${iconHtml}
|
||
</div>
|
||
</div>
|
||
<div class="preview-item-content">
|
||
<h6 class="preview-subject font-weight-normal mb-1">${this.escapeHtml(notification.title)}</h6>
|
||
<p class="font-weight-light small-text mb-0 text-muted">${this.escapeHtml(notification.message)}</p>
|
||
<p class="font-weight-light small-text mb-0 text-muted mt-1">
|
||
<small>${timeAgo}</small>
|
||
</p>
|
||
</div>
|
||
<button class="btn btn-sm btn-link text-danger ms-2"
|
||
data-action="click->notification#deleteNotification"
|
||
data-notification-id="${notification.id}"
|
||
style="padding: 0.25rem;">
|
||
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16">
|
||
<path d="M5.5 5.5A.5.5 0 0 1 6 6v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm2.5 0a.5.5 0 0 1 .5.5v6a.5.5 0 0 1-1 0V6a.5.5 0 0 1 .5-.5zm3 .5a.5.5 0 0 0-1 0v6a.5.5 0 0 0 1 0V6z"/>
|
||
<path fill-rule="evenodd" d="M14.5 3a1 1 0 0 1-1 1H13v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V4h-.5a1 1 0 0 1-1-1V2a1 1 0 0 1 1-1H6a1 1 0 0 1 1-1h2a1 1 0 0 1 1 1h3.5a1 1 0 0 1 1 1v1zM4.118 4 4 4.059V13a1 1 0 0 0 1 1h6a1 1 0 0 0 1-1V4.059L11.882 4H4.118zM2.5 3V2h11v1h-11z"/>
|
||
</svg>
|
||
</button>
|
||
</a>
|
||
`;
|
||
}
|
||
|
||
getIcon(type) {
|
||
const icons = {
|
||
user_joined: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 8a3 3 0 1 0 0-6 3 3 0 0 0 0 6zm2-3a2 2 0 1 1-4 0 2 2 0 0 1 4 0zm4 8c0 1-1 1-1 1H3s-1 0-1-1 1-4 6-4 6 3 6 4zm-1-.004c-.001-.246-.154-.986-.832-1.664C11.516 10.68 10.289 10 8 10c-2.29 0-3.516.68-4.168 1.332-.678.678-.83 1.418-.832 1.664h10z"/></svg>',
|
||
user_invited: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M224 0a128 128 0 1 1 0 256a128 128 0 1 1 0-256m-45.7 304h91.4c20.6 0 40.4 3.5 58.8 9.9C323 331 320 349.1 320 368c0 59.5 29.5 112.1 74.8 144H29.7C13.3 512 0 498.7 0 482.3C0 383.8 79.8 304 178.3 304M352 368a144 144 0 1 1 288 0a144 144 0 1 1-288 0m144-80c-8.8 0-16 7.2-16 16v64c0 8.8 7.2 16 16 16h48c8.8 0 16-7.2 16-16s-7.2-16-16-16h-32v-48c0-8.8-7.2-16-16-16"/></svg>',
|
||
user_accepted: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M96 128a128 128 0 1 1 256 0a128 128 0 1 1-256 0M0 482.3C0 383.8 79.8 304 178.3 304h91.4c98.5 0 178.3 79.8 178.3 178.3c0 16.4-13.3 29.7-29.7 29.7H29.7C13.3 512 0 498.7 0 482.3M625 177L497 305c-9.4 9.4-24.6 9.4-33.9 0l-64-64c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0l47 47L591 143c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9z"/></svg>',
|
||
user_removed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/></svg>',
|
||
user_deactivated: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 640 512"><path fill="currentColor" d="M38.8 5.1C28.4-3.1 13.3-1.2 5.1 9.2s-6.3 25.5 4.1 33.7l592 464c10.4 8.2 25.5 6.3 33.7-4.1s6.3-25.5-4.1-33.7L353.3 251.6C407.9 237 448 187.2 448 128C448 57.3 390.7 0 320 0c-69.8 0-126.5 55.8-128 125.2zm225.5 299.2C170.5 309.4 96 387.2 96 482.3c0 16.4 13.3 29.7 29.7 29.7h388.6c3.9 0 7.6-.7 11-2.1z"/></svg>',
|
||
org_update: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 4.754a3.246 3.246 0 1 0 0 6.492 3.246 3.246 0 0 0 0-6.492zM5.754 8a2.246 2.246 0 1 1 4.492 0 2.246 2.246 0 0 1-4.492 0z"/><path d="M9.796 1.343c-.527-1.79-3.065-1.79-3.592 0l-.094.319a.873.873 0 0 1-1.255.52l-.292-.16c-1.64-.892-3.433.902-2.54 2.541l.159.292a.873.873 0 0 1-.52 1.255l-.319.094c-1.79.527-1.79 3.065 0 3.592l.319.094a.873.873 0 0 1 .52 1.255l-.16.292c-.892 1.64.901 3.434 2.541 2.54l.292-.159a.873.873 0 0 1 1.255.52l.094.319c.527 1.79 3.065 1.79 3.592 0l.094-.319a.873.873 0 0 1 1.255-.52l.292.16c1.64.893 3.434-.902 2.54-2.541l-.159-.292a.873.873 0 0 1 .52-1.255l.319-.094c1.79-.527 1.79-3.065 0-3.592l-.319-.094a.873.873 0 0 1-.52-1.255l.16-.292c.893-1.64-.902-3.433-2.541-2.54l-.292.159a.873.873 0 0 1-1.255-.52l-.094-.319z"/></svg>',
|
||
app_access: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M8 1a2 2 0 0 1 2 2v4H6V3a2 2 0 0 1 2-2zm3 6V3a3 3 0 0 0-6 0v4a2 2 0 0 0-2 2v5a2 2 0 0 0 2 2h6a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2z"/></svg>',
|
||
role_changed: '<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" class="mx-0 mb-1" viewBox="0 0 16 16"><path d="M9.405 1.05c-.413-1.4-2.397-1.4-2.81 0l-.1.34a1.464 1.464 0 0 1-2.105.872l-.31-.17c-1.283-.698-2.686.705-1.987 1.987l.169.311c.446.82.023 1.841-.872 2.105l-.34.1c-1.4.413-1.4 2.397 0 2.81l.34.1a1.464 1.464 0 0 1 .872 2.105l-.17.31c-.698 1.283.705 2.686 1.987 1.987l.311-.169a1.464 1.464 0 0 1 2.105.872l.1.34c.413 1.4 2.397 1.4 2.81 0l.1-.34a1.464 1.464 0 0 1 2.105-.872l.31.17c1.283.698 2.686-.705 1.987-1.987l-.169-.311a1.464 1.464 0 0 1 .872-2.105l.34-.1c1.4-.413 1.4-2.397 0-2.81l-.34-.1a1.464 1.464 0 0 1-.872-2.105l.17-.31c.698-1.283-.705-2.686-1.987-1.987l-.311.169a1.464 1.464 0 0 1-2.105-.872l-.1-.34zM8 10.93a2.929 2.929 0 1 1 0-5.86 2.929 2.929 0 0 1 0 5.858z"/></svg>',
|
||
};
|
||
return icons[type] || icons.user_joined;
|
||
}
|
||
|
||
getIconBgClass(type) {
|
||
const classes = {
|
||
user_joined: 'bg-primary',
|
||
user_invited: 'bg-info',
|
||
user_accepted: 'bg-primary',
|
||
user_removed: 'bg-danger',
|
||
user_deactivated: 'bg-warning',
|
||
org_update: 'bg-warning',
|
||
app_access: 'bg-primary',
|
||
role_changed: 'bg-info',
|
||
};
|
||
return classes[type] || 'bg-primary';
|
||
}
|
||
|
||
getTimeAgo(dateString) {
|
||
if (!dateString) return '';
|
||
const date = new Date(dateString);
|
||
if (isNaN(date)) return '';
|
||
const now = new Date();
|
||
const seconds = Math.floor((now - date) / 1000);
|
||
|
||
if (seconds < 60) return 'À l\'instant';
|
||
if (seconds < 3600) {
|
||
const mins = Math.floor(seconds / 60);
|
||
return `Il y a ${mins} ${mins > 1 ? 'mins' : 'min'}`;
|
||
}
|
||
if (seconds < 86400) {
|
||
const hours = Math.floor(seconds / 3600);
|
||
return `Il y a ${hours} ${hours > 1 ? 'h' : 'h'}`;
|
||
}
|
||
if (seconds < 604800) {
|
||
const days = Math.floor(seconds / 86400);
|
||
return `Il y a ${days} ${days > 1 ? 'j' : 'j'}`;
|
||
}
|
||
// For older dates, show a localized date string
|
||
try {
|
||
return date.toLocaleDateString('fr-FR', {year: 'numeric', month: 'short', day: 'numeric'});
|
||
} catch (e) {
|
||
return date.toLocaleDateString();
|
||
}
|
||
}
|
||
|
||
escapeHtml(text) {
|
||
const div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
async markAsRead(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const notificationId = event.currentTarget.dataset.notificationId;
|
||
|
||
try {
|
||
await fetch(`/notifications/${notificationId}/read`, {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
});
|
||
|
||
this.loadNotifications();
|
||
} catch (error) {
|
||
console.error('Failed to mark notification as read:', error);
|
||
}
|
||
}
|
||
|
||
async markAllAsRead(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
try {
|
||
await fetch('/notifications/mark-all-read', {
|
||
method: 'POST',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
});
|
||
|
||
this.loadNotifications();
|
||
} catch (error) {
|
||
console.error('Failed to mark all as read:', error);
|
||
}
|
||
}
|
||
|
||
async deleteNotification(event) {
|
||
event.preventDefault();
|
||
event.stopPropagation();
|
||
|
||
const notificationId = event.currentTarget.dataset.notificationId;
|
||
|
||
try {
|
||
await fetch(`/notifications/${notificationId}`, {
|
||
method: 'DELETE',
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
}
|
||
});
|
||
|
||
this.loadNotifications();
|
||
} catch (error) {
|
||
console.error('Failed to delete notification:', error);
|
||
}
|
||
}
|
||
|
||
markDropdownAsRead(event) {
|
||
}
|
||
|
||
createToastContainer() {
|
||
let container = document.getElementById('notification-toast-container');
|
||
if (!container) {
|
||
container = document.createElement('div');
|
||
container.id = 'notification-toast-container';
|
||
container.className = 'notification-toast-container';
|
||
document.body.appendChild(container);
|
||
}
|
||
return container;
|
||
}
|
||
|
||
showToast(notification) {
|
||
const toast = document.createElement('div');
|
||
toast.className = 'notification-toast';
|
||
toast.innerHTML = `
|
||
<div class="notification-toast-icon ${this.getIconBgClass(notification.type)}">
|
||
${this.getIcon(notification.type)}
|
||
</div>
|
||
<div class="notification-toast-content">
|
||
<div class="notification-toast-title">${this.escapeHtml(notification.title)}</div>
|
||
<div class="notification-toast-message">${this.escapeHtml(notification.message)}</div>
|
||
</div>
|
||
<button class="notification-toast-close" onclick="this.parentElement.remove()">×</button>
|
||
`;
|
||
|
||
this.toastContainer.appendChild(toast);
|
||
|
||
setTimeout(() => toast.classList.add('show'), 10);
|
||
|
||
setTimeout(() => {
|
||
toast.classList.remove('show');
|
||
setTimeout(() => toast.remove(), 300);
|
||
}, 5000);
|
||
}
|
||
}
|