Easy_solution/assets/controllers/notification_controller.js

324 lines
16 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.

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);
}
}