Files
LEDMatrix/web_interface/static/v3/js/widgets/notification.js
ChuckBuildsandClaude Sonnet 5 e00d10ce60 fix(web): resolve Codacy findings — DOM building over innerHTML, Map callbacks, misc lint
Verified each of the 27 reported findings against current code; all fixed
except one rule class skipped with reason below.

- app-early.js: plugin tab buttons are now built with createElement/
  createTextNode instead of innerHTML template strings (icon class and name
  come from plugin manifests - semi-trusted input; the old code escaped the
  name but interpolated the icon class). Both construction sites. Also the
  forEach arrow no longer returns tab.remove()'s value.
- plugin-order-list.js: rows, empty state, and error state all built with
  DOM APIs - the file no longer contains innerHTML at all (the now-unneeded
  escapeHtml/escapeAttr helpers are removed); MODE_LABELS is a Map so the
  vegas-mode lookup can't hit prototype properties.
- notification.js: actionCallbacks is a Map (get/set/delete) instead of a
  plain object - resolves the object-injection-sink and dynamic-delete
  findings; triggerAction also type-checks the callback.
- htmx-config.js: unused catch binding dropped; var -> const in the
  afterSettle handler; the swapped-<script> re-execution reads/writes
  textContent instead of innerHTML; the diagnostic form payload uses a
  null-prototype object so a field named __proto__ can't pollute.
- custom-feeds-helpers.js DELETED (with its script tag): all three of its
  functions (addCustomFeedRow, removeCustomFeedRow,
  handleCustomFeedLogoUpload) are shadowed by the deferred
  widgets/custom-feeds.js window assignments, which always win at call time
  - the copies were dead even when they lived inline in base.html. This
  also resolves the unused-function and unused-variable findings there.

Skipped: 4x "Non-serializable expression must be wrapped with $(...)" in
app-early.js - that rule targets code crossing a browser-automation
serialization boundary (e.g. page.evaluate); these are ordinary arrow
functions in plain browser code with no such boundary.

Validation: all 40 web tests pass (incl. the static-asset reference audit,
which confirms no template still points at the deleted file); Jinja parse OK.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KEZK1P1Q1fu5pcuVrkrCFZ
2026-07-16 13:11:17 -04:00

311 lines
11 KiB
JavaScript

/**
* LEDMatrix Notification Widget
*
* Global notification/toast system for displaying messages to users.
* Consolidates all notification functionality into a single widget.
*
* Usage:
* window.showNotification('Message here', 'success');
* window.showNotification('Error occurred', 'error');
* window.LEDMatrixWidgets.get('notification').show('Custom message', { type: 'warning', duration: 5000 });
*
* Types: success, error, warning, info (default)
*
* @module NotificationWidget
*/
(function() {
'use strict';
// Ensure LEDMatrixWidgets registry exists
if (typeof window.LEDMatrixWidgets === 'undefined') {
console.error('[NotificationWidget] LEDMatrixWidgets registry not found. Load registry.js first.');
return;
}
// Configuration
const CONFIG = {
containerId: 'notifications',
defaultDuration: 4000,
fadeOutDuration: 300,
maxNotifications: 5,
position: 'top-right' // top-right, top-left, bottom-right, bottom-left
};
// Type-specific styling
const TYPE_STYLES = {
success: {
bg: 'bg-green-500',
icon: 'fa-check-circle',
label: 'Success'
},
error: {
bg: 'bg-red-500',
icon: 'fa-exclamation-circle',
label: 'Error'
},
warning: {
bg: 'bg-yellow-500',
icon: 'fa-exclamation-triangle',
label: 'Warning'
},
info: {
bg: 'bg-blue-500',
icon: 'fa-info-circle',
label: 'Info'
}
};
// Track active notifications
let activeNotifications = [];
// onAction callbacks for notifications with an inline action button,
// keyed by notification id (cleaned up on dismiss). A Map rather than a
// plain object so ids can never collide with prototype properties.
const actionCallbacks = new Map();
let notificationCounter = 0;
/**
* Get or create the notifications container
* @returns {HTMLElement} Container element
*/
function getContainer() {
let container = document.getElementById(CONFIG.containerId);
if (!container) {
container = document.createElement('div');
container.id = CONFIG.containerId;
container.className = 'fixed top-4 right-4 z-50 space-y-2 pointer-events-none';
container.setAttribute('aria-live', 'polite');
container.setAttribute('aria-label', 'Notifications');
document.body.appendChild(container);
}
return container;
}
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = String(text);
return div.innerHTML;
}
/**
* Remove a notification by ID
* @param {string} notificationId - Notification ID
* @param {boolean} immediate - Skip fade animation
*/
function removeNotification(notificationId, immediate = false) {
const notification = document.getElementById(notificationId);
if (!notification) return;
if (immediate) {
notification.remove();
} else {
notification.style.transition = `opacity ${CONFIG.fadeOutDuration}ms, transform ${CONFIG.fadeOutDuration}ms`;
notification.style.opacity = '0';
notification.style.transform = 'translateX(100%)';
setTimeout(() => {
notification.remove();
}, CONFIG.fadeOutDuration);
}
// Remove from tracking array
activeNotifications = activeNotifications.filter(id => id !== notificationId);
actionCallbacks.delete(notificationId);
}
/**
* Show a notification
* @param {string} message - Message to display
* @param {Object|string} options - Options object or type string
* @returns {string} Notification ID (for manual dismissal)
*/
function showNotification(message, options = {}) {
// Handle legacy call signature: showNotification(message, type)
if (typeof options === 'string') {
options = { type: options };
}
const type = options.type || 'info';
const duration = options.duration !== undefined ? options.duration : CONFIG.defaultDuration;
const showIcon = options.showIcon !== false;
const dismissible = options.dismissible !== false;
const style = TYPE_STYLES[type] || TYPE_STYLES.info;
const container = getContainer();
const notificationId = `notification_${++notificationCounter}`;
// Enforce max notifications limit
while (activeNotifications.length >= CONFIG.maxNotifications) {
removeNotification(activeNotifications[0], true);
}
// Create notification element
const notification = document.createElement('div');
notification.id = notificationId;
notification.className = `${style.bg} text-white px-4 py-3 rounded-lg shadow-lg flex items-center gap-3 pointer-events-auto transform transition-all duration-300 ease-out`;
notification.style.opacity = '0';
notification.style.transform = 'translateX(100%)';
notification.setAttribute('role', 'alert');
// Build content
let html = '';
if (showIcon) {
html += `<i class="fas ${style.icon} flex-shrink-0"></i>`;
}
html += `<span class="flex-1 text-sm">${escapeHtml(message)}</span>`;
// Optional inline action button (e.g. "Restart Now" on a restart nudge).
// The callback is stored by id and invoked via triggerAction, which
// also dismisses the notification.
if (options.actionLabel && typeof options.onAction === 'function') {
actionCallbacks.set(notificationId, options.onAction);
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').triggerAction('${notificationId}')"
class="flex-shrink-0 ml-2 px-3 py-1 text-xs font-semibold rounded-md bg-white bg-opacity-20 hover:bg-opacity-30 transition-colors duration-150">
${escapeHtml(options.actionLabel)}
</button>
`;
}
if (dismissible) {
html += `
<button type="button"
onclick="window.LEDMatrixWidgets.get('notification').dismiss('${notificationId}')"
class="flex-shrink-0 ml-2 w-5 h-5 flex items-center justify-center rounded-full opacity-70 hover:opacity-100 hover:bg-white hover:bg-opacity-20 transition-all duration-150"
aria-label="Dismiss notification">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
`;
}
notification.innerHTML = html;
container.appendChild(notification);
activeNotifications.push(notificationId);
// Trigger animation (need to wait for DOM update)
requestAnimationFrame(() => {
notification.style.opacity = '1';
notification.style.transform = 'translateX(0)';
});
// Auto-dismiss (0 = no auto-dismiss)
if (duration > 0) {
setTimeout(() => {
removeNotification(notificationId);
}, duration);
}
// Log for debugging
console.log(`[${type.toUpperCase()}]`, message);
return notificationId;
}
/**
* Clear all active notifications
*/
function clearAll() {
const ids = [...activeNotifications];
ids.forEach(id => { removeNotification(id, true); });
}
// Register the widget
window.LEDMatrixWidgets.register('notification', {
name: 'Notification Widget',
version: '1.0.0',
/**
* Show a notification
* @param {string} message - Message to display
* @param {Object} options - Configuration options
* @param {string} options.type - Notification type: success, error, warning, info
* @param {number} options.duration - Auto-dismiss duration in ms (0 = no auto-dismiss)
* @param {boolean} options.showIcon - Show type icon (default: true)
* @param {boolean} options.dismissible - Show dismiss button (default: true)
* @returns {string} Notification ID
*/
show: showNotification,
/**
* Dismiss a specific notification
* @param {string} notificationId - Notification ID to dismiss
*/
dismiss: function(notificationId) {
removeNotification(notificationId);
},
/**
* Invoke a notification's onAction callback (see options.actionLabel /
* options.onAction on show) and dismiss it.
* @param {string} notificationId - Notification ID whose action to run
*/
triggerAction: function(notificationId) {
const cb = actionCallbacks.get(notificationId);
removeNotification(notificationId);
if (typeof cb === 'function') cb();
},
/**
* Clear all notifications
*/
clearAll: clearAll,
/**
* Get active notification count
* @returns {number} Number of active notifications
*/
getActiveCount: function() {
return activeNotifications.length;
},
// Widget interface methods (for consistency with other widgets)
render: function() {
// Notification widget doesn't render into a container
// It manages its own container
getContainer();
},
getValue: function() {
return activeNotifications.length;
},
setValue: function() {
// No-op for notification widget
},
handlers: {
dismiss: function(notificationId) {
removeNotification(notificationId);
}
}
});
// Global shorthand function (backwards compatible with existing code).
// Accepts either the legacy type string or a full options object
// ({ type, duration, actionLabel, onAction, ... }).
window.showNotification = function(message, type = 'info') {
return showNotification(message, typeof type === 'string' ? { type: type } : (type || {}));
};
// Initialize container on load
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', getContainer);
} else {
getContainer();
}
console.log('[NotificationWidget] Notification widget registered');
})();