A free, open, self-hosted app platform (GNU AGPLv3): one-click app deploys, Traefik reverse proxy with automatic SSL, rootless Docker support, gluetun VPN routing, and a web dashboard to manage it all. Free & open forever to self-host; optional paid hosted services fund it. See PROMISE.md. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> Signed-off-by: librelad <librelad@digitalangels.vip>
449 lines
17 KiB
JavaScript
Executable File
449 lines
17 KiB
JavaScript
Executable File
/**
|
|
* Enhanced Notification System for LibrePortal
|
|
* Provides consistent, modular notifications with app icons and proper layout
|
|
*/
|
|
class NotificationSystem {
|
|
constructor() {
|
|
this.container = null;
|
|
this.init();
|
|
}
|
|
|
|
init() {
|
|
// Create notification container
|
|
this.container = document.createElement('div');
|
|
this.container.className = 'notification-container';
|
|
document.body.appendChild(this.container);
|
|
|
|
// Restore any pending notifications from localStorage
|
|
this.restoreNotifications();
|
|
}
|
|
|
|
/**
|
|
* Show a notification with consistent layout
|
|
* Layout: [Type Icon] [App Icon] [Message] [Action Button] [Close]
|
|
*
|
|
* `customIcon` is an optional override for the leftmost icon slot — when
|
|
* supplied, the notification renders that string/HTML there instead of
|
|
* the level-derived SVG (success tick / error cross / warning triangle).
|
|
* Used by task notifications so the icon reflects the task *type*
|
|
* (install ✅, backup 💾, restore 📦, …) rather than just success/fail.
|
|
*/
|
|
show(message, type = 'info', appName = null, appUrl = null, appIcon = null, customIcon = null) {
|
|
const notification = this.createNotificationElement(type, message, appName, appUrl, appIcon, customIcon);
|
|
this.addNotificationToContainer(notification);
|
|
this.saveNotificationToStorage({ message, type, appName, appUrl, appIcon, customIcon });
|
|
this.setupAutoRemove(notification);
|
|
return notification;
|
|
}
|
|
|
|
/**
|
|
* Create notification element with consistent structure
|
|
*/
|
|
createNotificationElement(type, message, appName, appUrl, appIcon, customIcon = null) {
|
|
const notification = document.createElement('div');
|
|
notification.className = `notification notification-${type}`;
|
|
|
|
// Add data attributes for dynamic sizing
|
|
if (appIcon) {
|
|
notification.setAttribute('data-has-app', 'true');
|
|
}
|
|
if (appName && appUrl) {
|
|
notification.setAttribute('data-has-action', 'true');
|
|
}
|
|
|
|
const typeIcon = customIcon != null && customIcon !== '' ? customIcon : this.getIcon(type);
|
|
const content = this.buildNotificationContent(typeIcon, message, appName, appUrl, appIcon, type);
|
|
|
|
notification.innerHTML = content;
|
|
|
|
// Attach event listeners dynamically for action button
|
|
if (appName && appUrl) {
|
|
const actionBtn = notification.querySelector('.notification-action-btn');
|
|
if (actionBtn) {
|
|
actionBtn.addEventListener('click', (e) => {
|
|
console.log('🔗 Notification action button clicked for URL:', appUrl);
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
if (window.handleNotificationNavigation) {
|
|
window.handleNotificationNavigation(appUrl);
|
|
} else {
|
|
console.error('❌ handleNotificationNavigation not available');
|
|
}
|
|
});
|
|
// Remove any other click handlers that might interfere
|
|
actionBtn.style.cursor = 'pointer';
|
|
} else {
|
|
console.warn('⚠️ Action button not found in notification');
|
|
}
|
|
}
|
|
|
|
// Attach event listener for close button
|
|
const closeBtn = notification.querySelector('.notification-close');
|
|
if (closeBtn) {
|
|
closeBtn.addEventListener('click', (e) => {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
notification.remove();
|
|
});
|
|
}
|
|
|
|
return notification;
|
|
}
|
|
|
|
/**
|
|
* Build notification HTML content
|
|
*/
|
|
buildNotificationContent(typeIcon, message, appName, appUrl, appIcon, type) {
|
|
let content = '<div class="notification-content">';
|
|
|
|
// Type icon (always first)
|
|
content += `<div class="notification-icon">${typeIcon}</div>`;
|
|
|
|
// App icon (if provided)
|
|
if (appIcon) {
|
|
content += `
|
|
<div class="notification-app-icon">
|
|
<img src="${appIcon}" alt="${appName}" onerror="this.onerror=null; this.src='icons/apps/default.svg'">
|
|
</div>
|
|
`;
|
|
}
|
|
|
|
// Message section
|
|
content += `<div class="notification-message">${message}</div>`;
|
|
|
|
// Action button (context-aware)
|
|
if (appName && appUrl) {
|
|
let buttonText = 'Manage';
|
|
if (appUrl.includes('task=')) {
|
|
buttonText = 'View Task';
|
|
} else if (type === 'success' && message.includes('install')) {
|
|
buttonText = 'Configure';
|
|
} else if (type === 'info' || type === 'warning') {
|
|
buttonText = 'View Task';
|
|
}
|
|
|
|
content += `
|
|
<button class="notification-action-btn">
|
|
${buttonText}
|
|
</button>
|
|
`;
|
|
}
|
|
|
|
// Close button (always last)
|
|
content += `
|
|
<button class="notification-close">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
|
<line x1="18" y1="6" x2="6" y2="18"></line>
|
|
<line x1="6" y1="6" x2="18" y2="18"></line>
|
|
</svg>
|
|
</button>
|
|
</div>`;
|
|
|
|
return content;
|
|
}
|
|
|
|
/**
|
|
* Get icon based on notification type
|
|
*/
|
|
getIcon(type) {
|
|
const icons = {
|
|
success: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="20 6 9 17 4 12"></polyline></svg>',
|
|
error: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line></svg>',
|
|
warning: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line></svg>',
|
|
info: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line></svg>',
|
|
uninstall: '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#ef4444" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg>'
|
|
};
|
|
return icons[type] || icons.info;
|
|
}
|
|
|
|
/**
|
|
* Add notification to container with animation
|
|
*/
|
|
addNotificationToContainer(notification) {
|
|
this.container.appendChild(notification);
|
|
|
|
// Trigger animation
|
|
setTimeout(() => {
|
|
notification.classList.add('notification-show');
|
|
}, 10);
|
|
}
|
|
|
|
/**
|
|
* Setup auto-remove after 10 seconds
|
|
*/
|
|
setupAutoRemove(notification) {
|
|
setTimeout(() => {
|
|
if (notification.parentElement) {
|
|
notification.classList.add('notification-hide');
|
|
setTimeout(() => {
|
|
if (notification.parentElement) {
|
|
notification.remove();
|
|
}
|
|
}, 300);
|
|
}
|
|
}, 10000);
|
|
}
|
|
|
|
/**
|
|
* Save notification to localStorage for cross-page persistence
|
|
*/
|
|
saveNotificationToStorage(notificationData) {
|
|
try {
|
|
const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]');
|
|
notificationData.id = Date.now().toString();
|
|
notificationData.timestamp = Date.now();
|
|
notifications.push(notificationData);
|
|
|
|
// Keep only last 5 notifications to avoid clutter
|
|
if (notifications.length > 5) {
|
|
notifications.shift();
|
|
}
|
|
|
|
localStorage.setItem('libreportal_notifications', JSON.stringify(notifications));
|
|
} catch (error) {
|
|
console.error('Error saving notification to localStorage:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Restore notifications from localStorage on page load
|
|
*/
|
|
restoreNotifications() {
|
|
try {
|
|
const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]');
|
|
const now = Date.now();
|
|
|
|
notifications.forEach(notificationData => {
|
|
const age = now - notificationData.timestamp;
|
|
const remainingTime = 10000 - age; // 10 seconds
|
|
|
|
if (remainingTime > 0) {
|
|
const notification = this.createNotificationElement(
|
|
notificationData.type,
|
|
notificationData.message,
|
|
notificationData.appName,
|
|
notificationData.appUrl,
|
|
notificationData.appIcon,
|
|
notificationData.customIcon
|
|
);
|
|
|
|
this.addNotificationToContainer(notification);
|
|
this.setupAutoRemove(notification, remainingTime);
|
|
}
|
|
});
|
|
} catch (error) {
|
|
console.error('Error restoring notifications:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove notification from localStorage
|
|
*/
|
|
removeNotification(notificationId) {
|
|
try {
|
|
const notifications = JSON.parse(localStorage.getItem('libreportal_notifications') || '[]');
|
|
const filteredNotifications = notifications.filter(n => n.id !== notificationId);
|
|
localStorage.setItem('libreportal_notifications', JSON.stringify(filteredNotifications));
|
|
} catch (error) {
|
|
console.error('Error removing notification:', error);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convenience methods
|
|
*/
|
|
success(message, appName = null, appUrl = null, appIcon = null) {
|
|
return this.show(message, 'success', appName, appUrl, appIcon);
|
|
}
|
|
|
|
error(message, appName = null, appUrl = null, appIcon = null) {
|
|
return this.show(message, 'error', appName, appUrl, appIcon);
|
|
}
|
|
|
|
warning(message, appName = null, appUrl = null, appIcon = null) {
|
|
return this.show(message, 'warning', appName, appUrl, appIcon);
|
|
}
|
|
|
|
info(message, appName = null, appUrl = null, appIcon = null) {
|
|
return this.show(message, 'info', appName, appUrl, appIcon);
|
|
}
|
|
|
|
/**
|
|
* Show install notification with short app name
|
|
*/
|
|
showInstallNotification(appName, appUrl, appIcon = null) {
|
|
const shortName = appName.split(' - ')[0];
|
|
const message = `${shortName} has been installed.`;
|
|
return this.show(message, 'success', appName, appUrl, appIcon);
|
|
}
|
|
|
|
/**
|
|
* Show uninstall notification with short app name
|
|
*/
|
|
showUninstallNotification(appName, appIcon = null) {
|
|
const shortName = appName.split(' - ')[0];
|
|
const message = `${shortName} has been uninstalled.`;
|
|
return this.show(message, 'uninstall', appName, null, appIcon);
|
|
}
|
|
}
|
|
|
|
// Notification system initialization is now handled by SystemLoader
|
|
// NotificationSystem instance will be created centrally
|
|
|
|
// Resolve a slug (e.g. "ipinfo") to its proper display name from window.apps
|
|
// (e.g. "IPInfo"). Falls back to a capitalized slug if window.apps isn't loaded
|
|
// or no match is found. The slug is the trailing token of an app's `command`.
|
|
window.getAppDisplayName = function (slug) {
|
|
if (!slug) return '';
|
|
const apps = window.apps || [];
|
|
const match = apps.find(a => {
|
|
const command = a.command || '';
|
|
return command.endsWith(` ${slug}`) || a.name?.toLowerCase() === slug.toLowerCase();
|
|
});
|
|
if (match && match.name) {
|
|
return match.name.split(' - ')[0].trim();
|
|
}
|
|
return slug.charAt(0).toUpperCase() + slug.slice(1);
|
|
};
|
|
|
|
// Expose helper functions globally
|
|
window.ensureNotificationSystem = () => {
|
|
if (!window.notificationSystem) {
|
|
console.warn('Notification system not initialized, creating new instance');
|
|
window.notificationSystem = new NotificationSystem();
|
|
}
|
|
return window.notificationSystem;
|
|
};
|
|
|
|
window.removeNotification = (notificationId) => {
|
|
if (window.notificationSystem) {
|
|
window.notificationSystem.removeNotification(notificationId);
|
|
}
|
|
};
|
|
|
|
window.handleNotificationNavigation = (url) => {
|
|
try {
|
|
console.log('🔗 handleNotificationNavigation called with URL:', url);
|
|
|
|
// Parse the URL to extract task ID and app name
|
|
const urlObj = new URL(url, window.location.origin);
|
|
const urlParams = new URLSearchParams(urlObj.search);
|
|
|
|
// Extract app name from ?= parameter (the key is empty string, = is the separator)
|
|
const appName = urlParams.get('') || urlParams.get('=');
|
|
const taskId = urlParams.get('task');
|
|
const tab = urlParams.get('tab') || 'tasks';
|
|
|
|
console.log('🔗 Parsed URL:', { url, appName, taskId, tab, currentPath: window.location.pathname });
|
|
|
|
// Check if we're on an app page or tasks page
|
|
const currentPath = window.location.pathname;
|
|
|
|
if (currentPath.includes('/app') && appName) {
|
|
console.log('🔗 On app page, appTabbedManager available:', !!window.appTabbedManager);
|
|
console.log('🔗 Current app:', window.appTabbedManager?.currentApp, 'Target app:', appName);
|
|
|
|
// We're on an app page - navigate to the specified app and tab
|
|
if (window.appTabbedManager) {
|
|
// Update the URL to the target app/tab/task
|
|
const newUrl = `/app?=${appName}&tab=${tab}&task=${taskId}`;
|
|
console.log('🔗 Pushing state to URL:', newUrl);
|
|
window.history.pushState({}, '', newUrl);
|
|
|
|
// If already on this app, just switch tab and highlight task
|
|
if (window.appTabbedManager.currentApp === appName) {
|
|
console.log('🔗 Same app, switching to tab:', tab);
|
|
window.appTabbedManager.switchTab(tab);
|
|
if (tab === 'tasks' && taskId) {
|
|
// Wait for tasks to load and render, then open the task details
|
|
setTimeout(() => {
|
|
console.log('🔗 Opening task details for:', taskId);
|
|
if (typeof window.toggleAppTaskDetails === 'function') {
|
|
window.toggleAppTaskDetails(taskId);
|
|
// Scroll to task after opening details
|
|
if (window.appTabbedManager && window.appTabbedManager.scrollToTask) {
|
|
window.appTabbedManager.scrollToTask(taskId);
|
|
}
|
|
}
|
|
}, 800);
|
|
}
|
|
console.log('🔗 Navigation completed successfully');
|
|
} else {
|
|
// Different app - need to load the full app detail
|
|
console.log('🔗 Different app, loading app detail for:', appName);
|
|
window.appTabbedManager.showAppDetail(appName);
|
|
// Schedule the tab switch and task highlight after app loads
|
|
setTimeout(() => {
|
|
if (window.appTabbedManager) {
|
|
console.log('🔗 Switching to tab:', tab);
|
|
window.appTabbedManager.switchTab(tab);
|
|
if (tab === 'tasks' && taskId) {
|
|
// Wait for tasks to load and render, then open the task details
|
|
setTimeout(() => {
|
|
console.log('🔗 Opening task details for:', taskId);
|
|
if (typeof window.toggleAppTaskDetails === 'function') {
|
|
window.toggleAppTaskDetails(taskId);
|
|
// Scroll to task after opening details
|
|
if (window.appTabbedManager && window.appTabbedManager.scrollToTask) {
|
|
window.appTabbedManager.scrollToTask(taskId);
|
|
}
|
|
}
|
|
}, 800);
|
|
}
|
|
}
|
|
}, 500);
|
|
console.log('🔗 Navigation to different app started');
|
|
}
|
|
return true;
|
|
} else {
|
|
console.warn('⚠️ appTabbedManager not available');
|
|
}
|
|
} else if (currentPath.includes('/tasks')) {
|
|
// We're on the tasks page, navigate to the specified task
|
|
if (taskId) {
|
|
console.log('🔗 On tasks page, opening task:', taskId);
|
|
window.history.pushState({}, '', `/tasks?=all&task=${taskId}`);
|
|
setTimeout(() => {
|
|
if (typeof window.toggleTaskDetails === 'function') {
|
|
console.log('🔗 Opening task details for:', taskId);
|
|
window.toggleTaskDetails(taskId);
|
|
}
|
|
}, 300);
|
|
return true;
|
|
}
|
|
} else {
|
|
// Not on app or tasks page - navigate to the app's tasks tab
|
|
if (appName && tab) {
|
|
window.history.pushState({}, '', `/app?=${appName}&tab=${tab}&task=${taskId}`);
|
|
// Let the SPA handle the navigation
|
|
if (window.appTabbedManager) {
|
|
window.appTabbedManager.showAppDetail(appName);
|
|
setTimeout(() => {
|
|
if (window.appTabbedManager) {
|
|
window.appTabbedManager.switchTab(tab);
|
|
if (window.tasksManager && taskId) {
|
|
window.tasksManager.highlightedTaskId = taskId;
|
|
if (window.tasksManager.loadTaskLogs) {
|
|
window.tasksManager.loadTaskLogs(taskId);
|
|
}
|
|
}
|
|
}
|
|
}, 500);
|
|
return true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// If we get here and no managers were available, fallback
|
|
console.warn('⚠️ Falling back to page reload for URL:', url);
|
|
window.location.href = url;
|
|
return false;
|
|
} catch (error) {
|
|
console.error('❌ Error handling notification navigation:', error);
|
|
// Fallback to direct navigation if parsing fails
|
|
console.warn('⚠️ Falling back to page reload due to error for URL:', url);
|
|
window.location.href = url;
|
|
return false;
|
|
}
|
|
};
|