librelad 152d9c5d28 fix(webui): make all icon and data asset URLs absolute under path routing
Same class of bug as the topbar partial: icon and data-file references were
relative (icons/apps/x.svg, data/apps/...), so on deep path routes (/app/<name>,
/admin/config/x) the browser resolved them against the route dir and the SPA
catch-all served index.html with HTTP 200 instead of 404 — broken images and
silently-wrong JSON.

Make every reference absolute (anchored on the quote/backtick so already-absolute
/icons paths are untouched):
- JS: all icons/ and data/ literals + templates across components/utils/system
- html/topbar.html: logo <img>
- generators: webui_config.sh and webui_create_app_categories.sh now emit
  /icons/... into apps.json / apps-categories.json (regenerated on install)
- updated the two icon-path comments to match

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Signed-off-by: librelad <librelad@digitalangels.vip>
2026-05-24 23:20:42 +01:00

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