'use strict'; /* Initialize tooltips: bootstrap.bundle.js */ var tooltipTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="tooltip"]')) var tooltipList = tooltipTriggerList.map(function (tooltipTriggerEl) { return new bootstrap.Tooltip(tooltipTriggerEl) }) /* Initialize popovers: bootstrap.bundle.js */ var popoverTriggerList = [].slice.call(document.querySelectorAll('[data-bs-toggle="popover"]')) var popoverList = popoverTriggerList.map(function (popoverTriggerEl) { return new bootstrap.Popover(popoverTriggerEl) }) /* Set default dropdown behavior: bootstrap.bundle.js */ bootstrap.Dropdown.Default.autoClose = 'outside'; // Run gobal scripts: after all other scripts are loaded /* Initialize the navigation : smartNavigation.js */ let nav; const navElement = document.querySelector('#js-primary-nav'); if (navElement) { nav = new Navigation(navElement, { accordion: true, slideUpSpeed: 350, slideDownSpeed: 470, closedSign: '', openedSign: '', initClass: 'js-nav-built', debug: false, instanceId: `nav-${Date.now()}`, maxDepth: 5, sanitize: true, animationTiming: 'easeOutExpo', debounceTime: 0, onError: error => console.error('Navigation error:', error) }); } /* Waves Effect : waves.js */ if (window.Waves) { Waves.attach('.btn:not(.js-waves-off):not(.btn-switch):not(.btn-panel):not(.btn-system):not([data-action="playsound"]), .js-waves-on', ['waves-themed']); Waves.init(); } /* Initialize the list filter : listFilter.js */ document.addEventListener('DOMContentLoaded', function () { /* initialize smartApp.js */ appDOM.checkActiveStyles().debug(false); /* Initialize the list filter */ var listFilter = new ListFilter('#js-nav-menu', '#searchInput', { messageSelector: '.js-filter-message', debounceWait: 200, minLength: 2, caseSensitive: false, onFilter: function (filter) { console.log('Filtering with:', filter); }, onReset: function () { console.log('Filter reset'); } }); /* Panel Sorting : sortable.js Initialize Sortable for each column turn off sortable by adding "sortable-off" class to main-content before init this will however still load any saved state remove !mobileOperator() to enable sortable on mobile */ const columns = document.querySelectorAll('.main-content:not(.sortable-off) > .row:not(.sortable-off) > [class^="col-"]'); /* Check if columns exist and Sortable is defined and mobileOperator is false */ if (columns.length > 0 && typeof Sortable !== 'undefined' && !mobileOperator()) { /* Initialize Sortable for each column */ columns.forEach(column => { Sortable.create(column, { animation: 150, ghostClass: 'panel-selected', handle: '.panel-hdr > h2', filter: '.panel-locked', draggable: '.panel:not(.panel-locked):not(.panel-fullscreen)', group: 'sapanels', onEnd: function () { savePanelState(); } }); }); /* Add class to app-content if sortable is active */ document.querySelector('.main-content').classList.add('sortable-active'); } else { document.querySelector('.main-content').classList.add('sortable-inactive'); } /* Initialize the custom scrollbar : smartSlimScroll.js */ /* Customized Scrollbar : smartSlimScroll.js */ /* Initialize smartSlimScroll if not on mobile - In mobile we use native scrollbar for better UX */ if (!mobileOperator()) { /* Initialize smartSlimScroll */ new smartSlimScroll('.custom-scroll', { height: '100%', size: '4px', position: 'right', color: '#000', alwaysVisible: false, railVisible: true, railColor: '#222', railOpacity: 0, wheelStep: 10, offsetX: '6px', offsetY: '8px' }); } else { document.getElementsByTagName('BODY')[0].classList.add('no-slimscroll'); } }); // appDOM - Enhanced Vanilla JavaScript Plugin for DOM Manipulation and UI Controls (function (window) { 'use strict'; // Create global appDOM object window.appDOM = (function () { // Private variables and cache const htmlRoot = document.documentElement; const cache = { actionButtons: null, audioCache: new Map() }; // Configuration object with security limits const config = { debug: false, focusDelay: 200, defaultSoundPath: 'media/sound/', maxClassNameLength: 50, maxSelectorLength: 255, fullscreenConfirmMessage: 'Do you want to enter fullscreen mode?', selectors: { actionButtons: '[data-action]' }, sound: { preload: false, volume: 1.0, fadeIn: false, fadeInDuration: 500 }, classes: { classesToKeep: ['hide-page-scrollbar'] // Classes that won't be removed during reset }, theme: { styleId: 'theme-style', defaultTheme: '' } }; // Main plugin object const plugin = { // Check if string contains substring containsSubstring: function (string, substring) { if (!string || !substring) { logger.warn('Invalid parameters for containsSubstring'); return false; } const regex = new RegExp(`\\b${substring}\\b`, 'i'); return regex.test(string); }, // Reset style function resetStyle: function () { logger.group('Reset Style'); const dataAction = document.querySelectorAll(config.selectors.actionButtons); // Reset all action buttons dataAction.forEach(element => { if (element.type === 'checkbox') { element.checked = false; element.parentNode.classList.remove("active"); } else { element.classList.remove("active"); } }); // Keep specific classes while removing others const currentClasses = htmlRoot.className.split(' '); const filteredClasses = currentClasses.filter(className => config.classes.classesToKeep.includes(className) ); htmlRoot.className = filteredClasses.join(' '); // Reset theme toggle buttons' aria-pressed attribute to match current theme // This should happen after resetSettings() since that might change the theme resetSettings(); // After reset, check the current theme and update aria-pressed accordingly const currentTheme = htmlRoot.getAttribute('data-bs-theme') || 'light'; const isDarkTheme = currentTheme === 'dark'; document.querySelectorAll('[data-action="toggle-theme"]').forEach(button => { button.setAttribute('aria-pressed', isDarkTheme.toString()); }); // Remove the theme style element if it exists const themeStyleElement = document.getElementById(config.theme.styleId); if (themeStyleElement) { themeStyleElement.remove(); } // Reset radio buttons for theme selection document.querySelectorAll('input[data-action="theme-style"]').forEach(radio => { radio.checked = false; }); logger.log('Styles reset completed'); logger.groupEnd(); return plugin; } }; // Enhanced Security utilities const security = { sanitizeClassName: function (className) { if (typeof className !== 'string') { logger.error('Invalid class name type'); return ''; } // Length check to prevent DoS if (className.length > config.maxClassNameLength) { logger.error(`Class name exceeds maximum length of ${config.maxClassNameLength} characters`); return ''; } // Enhanced pattern to only allow valid CSS class characters const sanitized = className.replace(/[^a-zA-Z0-9-_]/g, '') .replace(/^[0-9-_]/, '') .substring(0, config.maxClassNameLength); if (sanitized !== className) { logger.warn(`Class name "${className}" contained invalid characters and was sanitized to "${sanitized}"`); } return sanitized; }, sanitizeSelector: function (selector) { if (typeof selector !== 'string') { logger.error('Invalid selector type'); return ''; } // Length check to prevent DoS if (selector.length > config.maxSelectorLength) { logger.error(`Selector exceeds maximum length of ${config.maxSelectorLength} characters`); return ''; } // Enhanced CSS injection prevention if (/<[^>]*>|javascript:|data:|@import|expression|url\(|eval\(|setTimeout|setInterval/i.test(selector)) { logger.error('Potential malicious selector detected'); return ''; } // Only allow valid CSS selectors and escape potentially dangerous characters const sanitized = selector.replace(/[<>"'`=\/\\]/g, '') .replace(/[\u0000-\u001F\u007F-\u009F]/g, '') .trim(); return sanitized; }, validateDataAttribute: function (attribute) { if (typeof attribute !== 'string') { logger.error('Invalid data attribute type'); return ''; } // Length check if (attribute.length > config.maxClassNameLength) { logger.error(`Data attribute exceeds maximum length of ${config.maxClassNameLength} characters`); return ''; } // Enhanced pattern for data attributes return attribute.replace(/[^a-zA-Z0-9-_]/g, '') .substring(0, config.maxClassNameLength); }, sanitizeUrl: function (url) { if (typeof url !== 'string') { logger.error('Invalid URL type'); return ''; } // Length check if (url.length > 2000) { // Standard URL length limit logger.error('URL exceeds maximum length'); return ''; } // Simple validation for CSS files const sanitized = url.replace(/[<>"'`]/g, ''); // Only allow CSS files and restrict to relative paths or same domain if (!sanitized.endsWith('.css')) { logger.error('URL must end with .css'); return ''; } // Check for potential script injection if (/javascript:|data:|file:|ftp:|@import|expression|eval\(|setTimeout|setInterval/i.test(sanitized)) { logger.error('Potentially malicious URL detected'); return ''; } return sanitized; }, checkFullscreenPermission: function () { if (!(document.fullscreenEnabled || document.webkitFullscreenEnabled || document.mozFullScreenEnabled || document.msFullscreenEnabled)) { logger.error('Fullscreen not supported in this browser'); return false; } if (window.self !== window.top) { try { if (!window.parent.document.fullscreenEnabled) { logger.error('Fullscreen not allowed in iframe'); return false; } } catch (e) { logger.error('Cannot access parent frame for fullscreen permission'); return false; } } return true; }, getClosestElement: function (element, selector) { if (!element || !selector) { logger.error('Invalid parameters for getClosestElement'); return null; } try { return element.closest(selector); } catch (error) { logger.error(`Error finding closest element: ${error.message}`); return null; } } }; // Debug logger const logger = { log: function (msg) { if (config.debug) console.log(`[SmartAdmin] ${msg}`); }, error: function (msg) { if (config.debug) console.error(`[SmartAdmin Error] ${msg}`); }, warn: function (msg) { if (config.debug) console.warn(`[SmartAdmin Warning] ${msg}`); }, group: function (name) { if (config.debug) console.group(`[SmartAdmin Group] ${name}`); }, groupEnd: function () { if (config.debug) console.groupEnd(); } }; // Audio Helper Functions const audioHelpers = { sanitizeFilename: function (filename) { return filename.replace(/[^\w.-]/g, ''); }, fadeInAudio: async function (audio, targetVolume, duration) { audio.volume = 0; const steps = 20; const increment = targetVolume / steps; const stepDuration = duration / steps; for (let i = 0; i <= steps; i++) { await new Promise(resolve => setTimeout(resolve, stepDuration)); audio.volume = Math.min(targetVolume, i * increment); } }, createAudioElement: function (path, sound, volume) { const audio = new Audio(); audio.src = `${path}${sound}`; audio.volume = volume; return audio; }, // Add new helper to stop all sounds except one stopAllSoundsExcept: function (exceptSound) { cache.audioCache.forEach((audio, key) => { if (key !== exceptSound && !audio.paused) { audio.pause(); audio.currentTime = 0; // Find and update any elements playing this sound const playingElements = document.querySelectorAll(`[data-audio-playing="${key}"]`); playingElements.forEach(el => el.removeAttribute('data-audio-playing')); } }); } }; // Utility functions const utils = { debounce: function (func, wait) { let timeout; return function executedFunction(...args) { const later = () => { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout = setTimeout(later, wait); }; }, validateElement: function (element, name) { if (!element) { logger.error(`${name} element not found`); return false; } return true; }, // Color extraction utilities extractColors: function () { logger.group('Color Extraction'); // example usage //console.log(window.colorMap.primary[500].hex); //console.log(window.colorMap.primary[500].rgb); //console.log(window.colorMap.primary[500].rgba(0.5)); //console.log(window.colorMap.bootstrapVars.bodyColor.hex); //console.log(window.colorMap.bootstrapVars.bodyColor.rgb); //console.log(window.colorMap.bootstrapVars.bodyColor.rgba(0.5)); // Initialize color map if not exists if (!window.colorMap) { window.colorMap = {}; logger.log('Created new colorMap object'); } else { logger.log('Using existing colorMap object'); } const categories = ["primary", "danger", "success", "warning", "info"]; const shades = Array.from({ length: 17 }, (_, i) => (i + 1) * 50).filter(shade => shade <= 900); const bootstrapVars = [ { name: "bodyBg", style: "background-color: var(--bs-body-bg)" }, { name: "bodyBgRgb", style: "background-color: rgb(var(--bs-body-bg-rgb))" }, { name: "bodyColor", style: "color: var(--bs-body-color)" }, { name: "bodyColorRgb", style: "color: rgb(var(--bs-body-color-rgb))" } ]; // Create temporary elements for color extraction const ul = document.createElement("ul"); ul.style.display = "none"; // Add category and shade elements categories.forEach(category => { shades.forEach(shade => { const li = document.createElement("li"); li.className = `bg-${category}-${shade}`; ul.appendChild(li); }); }); // Add bootstrap variable elements bootstrapVars.forEach(varInfo => { const li = document.createElement("li"); li.style.cssText = varInfo.style; li.dataset.varName = varInfo.name; ul.appendChild(li); }); // Create container and append to body const container = document.createElement("div"); container.id = "color-extraction-container"; container.appendChild(ul); document.body.appendChild(container); // Helper function to parse RGB string to object with values and formatted string const parseRgb = (rgbString) => { if (!rgbString || rgbString === "rgba(0, 0, 0, 0)" || rgbString === "transparent") { return { r: 0, g: 0, b: 0, formatted: "rgb(0, 0, 0)", values: "0, 0, 0" }; } const [r, g, b] = rgbString.match(/\d+/g).map(Number); return { r, g, b, formatted: rgbString, values: `${r}, ${g}, ${b}` }; }; // Extract colors for categories and shades categories.forEach(category => { // Initialize category if it doesn't exist if (!window.colorMap[category]) { window.colorMap[category] = {}; } shades.forEach(shade => { const element = ul.querySelector(`.bg-${category}-${shade}`); const bgColor = window.getComputedStyle(element).backgroundColor; const rgbData = parseRgb(bgColor); window.colorMap[category][shade] = { hex: this.rgbToHex(bgColor), rgb: rgbData.formatted, rgba: (opacity = 1) => `rgba(${rgbData.values}, ${opacity})`, values: rgbData.values }; }); }); // Extract bootstrap variable colors if (!window.colorMap.bootstrapVars) { window.colorMap.bootstrapVars = {}; logger.log('Created bootstrapVars in colorMap'); } else { logger.log('Updating existing bootstrapVars in colorMap'); } bootstrapVars.forEach(varInfo => { const element = ul.querySelector(`[data-var-name="${varInfo.name}"]`); const property = varInfo.name.includes("bodyColor") ? "color" : "backgroundColor"; const colorValue = window.getComputedStyle(element)[property]; const rgbData = parseRgb(colorValue); window.colorMap.bootstrapVars[varInfo.name] = { hex: this.rgbToHex(colorValue), rgb: rgbData.formatted, rgba: (opacity = 1) => `rgba(${rgbData.values}, ${opacity})`, values: rgbData.values }; }); // Clean up container.remove(); // Dispatch event when color map is ready window.dispatchEvent(new Event("colorMapReady")); logger.log('Color extraction completed'); logger.groupEnd(); }, rgbToHex: function (rgb) { if (!rgb || rgb === "rgba(0, 0, 0, 0)" || rgb === "transparent") return null; const [r, g, b] = rgb.match(/\d+/g).map(Number); return `#${((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1).padStart(6, '0')}`; } }; // Enhanced fullscreen handler const fullscreenHandler = { enter: function () { try { if (document.documentElement.requestFullscreen) { return document.documentElement.requestFullscreen(); } else if (document.documentElement.webkitRequestFullscreen) { return document.documentElement.webkitRequestFullscreen(); } else if (document.documentElement.mozRequestFullScreen) { return document.documentElement.mozRequestFullScreen(); } else if (document.documentElement.msRequestFullscreen) { return document.documentElement.msRequestFullscreen(); } throw new Error('No fullscreen API available'); } catch (error) { logger.error(`Fullscreen error: ${error.message}`); return Promise.reject(error); } }, exit: function () { try { if (document.exitFullscreen) { return document.exitFullscreen(); } else if (document.webkitExitFullscreen) { return document.webkitExitFullscreen(); } else if (document.mozCancelFullScreen) { return document.mozCancelFullScreen(); } else if (document.msExitFullscreen) { return document.msExitFullscreen(); } throw new Error('No fullscreen API available'); } catch (error) { logger.error(`Fullscreen exit error: ${error.message}`); return Promise.reject(error); } }, isFullscreen: function () { return !!(document.fullscreenElement || document.webkitFullscreenElement || document.mozFullScreenElement || document.msFullscreenElement); } }; // Function to update all chart colors after theme changes function updateAllPluginColors() { setTimeout(() => { if (typeof window.rebuildApexCharts === 'function') { //rebuild all apex charts window.rebuildApexCharts(); } if (typeof window.updateEasyPieCharts === 'function') { //re-color any easy pie charts window.updateEasyPieCharts(); } if (typeof window.updateMiscPluginColors === 'function') { //re-color any other page plugins window.updateMiscPluginColors(); } }, 150); } // Utility method to update aria-pressed on theme toggle buttons plugin.updateThemeButtonsState = function () { const currentTheme = htmlRoot.getAttribute('data-bs-theme') || 'light'; const isDarkTheme = currentTheme === 'dark'; document.querySelectorAll('[data-action="toggle-theme"]').forEach(button => { button.setAttribute('aria-pressed', isDarkTheme.toString()); logger.log(`Updated aria-pressed to ${isDarkTheme} on theme toggle button`); }); return plugin; }; // Utility method to update theme style states plugin.updateThemeStyleStates = function () { logger.group('Updating Theme Style States'); const themeStyle = document.getElementById('theme-style'); if (!themeStyle || !themeStyle.getAttribute('href')) { logger.log('No theme style element or href found'); logger.groupEnd(); return plugin; } const href = themeStyle.getAttribute('href'); const themeStyleName = href.split('/').pop().replace('.css', '').trim(); logger.log(`Current theme style: ${themeStyleName}`); document.querySelectorAll('[data-theme-style]').forEach(element => { const elementTheme = element.getAttribute('data-theme-style'); if (!elementTheme) return; const isMatch = elementTheme === themeStyleName; element.setAttribute('aria-selected', isMatch ? 'true' : 'false'); htmlRoot.classList.add('theme-active'); if (element.type === 'radio') { element.checked = isMatch; logger.log(`Radio button ${elementTheme}: checked=${isMatch}`); } else { if (isMatch) { element.classList.add('active'); } else { element.classList.remove('active'); } logger.log(`Element ${elementTheme}: active=${isMatch}`); } }); logger.groupEnd(); return plugin; }; // Expose extractColors in the public API // usage: window.appDOM.extractColors() plugin.extractColors = function () { utils.extractColors(); return plugin; }; // Theme Switcher Methods plugin.setThemeStyle = function (themeUrl) { logger.group('Setting Theme Style'); if (!themeUrl) { logger.error('Empty theme URL provided'); logger.groupEnd(); return plugin; } const sanitizedUrl = security.sanitizeUrl(themeUrl); if (!sanitizedUrl) { logger.error('Invalid theme URL'); logger.groupEnd(); return plugin; } // Function to handle CSS loading and updates function updateAfterCssLoad() { logger.log('Theme CSS fully loaded'); // Update states after setting the style plugin.updateThemeStyleStates(); // Update All Charts and plugin colors after CSS is loaded logger.log('Updating plugin colors after CSS load'); plugin.updateAllPluginColors(); // Save to localStorage AFTER the CSS is fully loaded if (typeof window.saveSettings === 'function') { logger.log('Saving theme selection to local storage after CSS loaded'); window.saveSettings(); } // Dispatch event that the theme has changed const event = new CustomEvent('themeStyleChange', { detail: { theme: sanitizedUrl } }); document.dispatchEvent(event); } // Get the head element const head = document.getElementsByTagName('head')[0]; const oldStyleElement = document.getElementById(config.theme.styleId); // Check if we're trying to load the same CSS that's already loaded if (oldStyleElement && oldStyleElement.getAttribute('href') === sanitizedUrl) { logger.log('Theme CSS already loaded with this URL'); logger.groupEnd(); return plugin; } // Create a new style element const newStyleElement = document.createElement('link'); newStyleElement.rel = 'stylesheet'; newStyleElement.media = 'screen'; // Special handling for the first theme change after page load // Check if the old style element was loaded from localStorage const loadedFromStorage = oldStyleElement && oldStyleElement.getAttribute('data-loaded-from-storage') === 'true'; // If this is the first theme change and it was loaded from storage, // we can use the main ID immediately for better transition const tempId = `${config.theme.styleId}-new`; if (loadedFromStorage) { logger.log('Handling initial theme that was loaded from storage'); newStyleElement.id = config.theme.styleId; newStyleElement.href = sanitizedUrl; // Set up the load handler before adding to DOM newStyleElement.onload = function () { // Remove storage flag as we're now managing it normally newStyleElement.removeAttribute('data-loaded-from-storage'); // Remove the old stylesheet if (oldStyleElement) { oldStyleElement.remove(); logger.log('Initial storage-loaded CSS removed after new one loaded'); } // Run the update function updateAfterCssLoad(); }; // Replace the old element directly for initial load if (oldStyleElement) { head.replaceChild(newStyleElement, oldStyleElement); } else { head.appendChild(newStyleElement); } logger.log(`Initial theme replaced with: ${sanitizedUrl}`); } else { // Normal case - use temporary ID until loaded newStyleElement.id = tempId; newStyleElement.href = sanitizedUrl; // Set up the load handler before adding to DOM newStyleElement.onload = function () { logger.log('New theme CSS loaded'); // Now that new CSS is loaded, rename it to the proper ID newStyleElement.id = config.theme.styleId; // Remove the old stylesheet if it exists (after new one is loaded) if (oldStyleElement) { oldStyleElement.remove(); logger.log('Old theme CSS removed'); } // Now run the update function updateAfterCssLoad(); }; // Add the new stylesheet to the DOM head.appendChild(newStyleElement); logger.log(`New theme link added: ${sanitizedUrl}`); } // Save to local storage if saveSettings is available if (typeof window.saveSettings === 'function') { logger.log('Saving theme selection to local storage'); window.saveSettings(); } // Provide a fallback in case the onload event doesn't fire setTimeout(() => { // Look for either the temp element or a storage-loaded element that hasn't been updated const temporaryElement = document.getElementById(tempId); const storageElement = document.getElementById(config.theme.styleId); if (temporaryElement) { // Handle case where temp element exists but onload didn't fire logger.log('CSS load timeout reached for temporary element, forcing update'); temporaryElement.id = config.theme.styleId; if (oldStyleElement) { oldStyleElement.remove(); } updateAfterCssLoad(); } else if (storageElement && storageElement.getAttribute('data-loaded-from-storage') === 'true') { // Handle case where storage element exists but onload didn't fire logger.log('CSS load timeout reached for storage-loaded element, forcing update'); storageElement.removeAttribute('data-loaded-from-storage'); updateAfterCssLoad(); } }, 2000); logger.groupEnd(); return plugin; }; // Check active styles plugin.checkActiveStyles = function (target, data) { logger.group('Checking Active Styles'); const classes = htmlRoot.className.split(" ") || target; const dataAction = document.querySelectorAll(config.selectors.actionButtons) || data; dataAction.forEach(element => { if (element.type === 'checkbox') { element.checked = false; element.parentNode.classList.remove("active"); } else { element.classList.remove("active"); } }); classes.forEach(className => { const sanitizedClass = security.sanitizeClassName(className); const elements = document.querySelectorAll(`[data-class="${sanitizedClass}"]`); elements.forEach(element => { if (element.type === 'checkbox') { element.checked = true; element.parentNode.classList.add("active"); } else { element.classList.add("active"); } }); }); // Also check and update theme style selectors const currentThemeLink = document.getElementById(config.theme.styleId); if (currentThemeLink) { plugin.updateThemeStyleStates(); } logger.groupEnd(); return plugin; }; // Make updateAllPluginColors function publicly accessible plugin.updateAllPluginColors = function () { updateAllPluginColors(); return plugin; }; // Enhanced handlers with all implementations const handlers = { toggle: function (element) { // Get and sanitize attributes const dataClass = element.getAttribute('data-class'); // Validate dataClass exists before sanitizing if (!dataClass) { logger.error('Missing required data-class attribute for toggle action'); return; } const sanitizedClass = security.sanitizeClassName(dataClass); const depClass = element.getAttribute('data-dependency'); const coDepClass = element.getAttribute('data-codependence'); const inputFocus = element.getAttribute('data-focus'); logger.group('Toggle Action'); logger.log(`Toggling class: ${sanitizedClass}`); htmlRoot.classList.toggle(sanitizedClass); if (depClass) { depClass.split(" ").forEach(cls => { const sanitizedCls = security.sanitizeClassName(cls); htmlRoot.classList.add(sanitizedCls); logger.log(`Added dependency class: ${sanitizedCls}`); }); } if (coDepClass) { coDepClass.split(" ").forEach(cls => { const sanitizedCls = security.sanitizeClassName(cls); htmlRoot.classList.remove(sanitizedCls); logger.log(`Removed codependency class: ${sanitizedCls}`); }); } if (inputFocus) { const sanitizedFocus = security.validateDataAttribute(inputFocus); setTimeout(() => { const focusElement = document.getElementById(sanitizedFocus); if (focusElement) focusElement.focus(); logger.log(`Focus set to element: ${sanitizedFocus}`); }, config.focusDelay); } if (typeof window.saveSettings === 'function') { window.saveSettings(); logger.log('🔑 Settings saved'); } plugin.checkActiveStyles(); logger.groupEnd(); }, toggleReplace: function (element) { const target = security.sanitizeSelector(element.getAttribute('data-target')); const removeClass = security.sanitizeClassName(element.getAttribute('data-removeclass')) || ""; const addClass = security.sanitizeClassName(element.getAttribute('data-addclass')); const targetElement = document.querySelector(target); logger.group('Toggle Replace Action'); if (targetElement) { targetElement.classList.remove(removeClass); targetElement.classList.add(addClass); logger.log(`Replaced class "${removeClass}" with "${addClass}" on ${target}`); } else { logger.error(`Target element not found: ${target}`); } logger.groupEnd(); }, toggleSwap: function (element) { const targetAttr = element.getAttribute('data-target'); const target = targetAttr ? security.sanitizeSelector(targetAttr) : 'html'; const toggleClass = security.sanitizeClassName(element.getAttribute('data-toggleclass')); logger.group('Toggle Swap Action'); if (!toggleClass) { logger.error('Missing required toggleclass attribute for toggle-swap, defaulting to HTML target'); logger.groupEnd(); return; } const targetElement = document.querySelector(target); if (!targetElement) { logger.error(`Target element not found: ${target}`); logger.groupEnd(); return; } targetElement.classList.toggle(toggleClass); logger.log(`Toggled class "${toggleClass}" on ${target}`); if (plugin.containsSubstring(target, 'drawer')) { htmlRoot.classList.toggle('hide-page-scrollbar'); logger.log('Toggled page scrollbar'); } logger.groupEnd(); }, panelActions: { hideTooltips: function (panel) { // Find all tooltips in the panel and hide them const tooltipTriggers = panel.querySelectorAll('[data-bs-toggle="tooltip"]'); tooltipTriggers.forEach(el => { const tooltip = bootstrap.Tooltip.getInstance(el); if (tooltip) { tooltip.hide(); } }); }, collapse: function (element) { logger.group('Panel Collapse Action'); const selectedPanel = security.getClosestElement(element, '.panel'); if (!selectedPanel) { logger.error('Panel element not found'); logger.groupEnd(); return; } // Hide tooltips before collapsing this.hideTooltips(selectedPanel); const panelContainer = selectedPanel.querySelector('.panel-container'); if (!panelContainer) { logger.error('Panel container not found'); logger.groupEnd(); return; } // Always ensure transition is set panelContainer.style.transition = 'height 0.35s ease'; // Store the current height before any changes const startHeight = panelContainer.scrollHeight; // Toggle panel collapsed state if (selectedPanel.classList.contains('panel-collapsed')) { // For expanding: First ensure we're at 0 height panelContainer.style.height = '0'; panelContainer.style.overflow = 'hidden'; // Force a reflow panelContainer.offsetHeight; // Remove collapsed class selectedPanel.classList.remove('panel-collapsed'); // Animate to full height panelContainer.style.height = startHeight + 'px'; // After animation completes setTimeout(function () { panelContainer.style.overflow = 'visible'; panelContainer.style.height = 'auto'; }, 350); logger.log('Panel uncollapsed: ' + selectedPanel.id); } else { // For collapsing: First set exact current height panelContainer.style.height = startHeight + 'px'; panelContainer.style.overflow = 'hidden'; // Force a reflow panelContainer.offsetHeight; // Add collapsed class selectedPanel.classList.add('panel-collapsed'); // Animate to 0 panelContainer.style.height = '0'; logger.log('Panel collapsed: ' + selectedPanel.id); } // Save panel state after toggle savePanelState(); logger.groupEnd(); }, fullscreen: function (element) { logger.group('Panel Fullscreen Action'); const selectedPanel = security.getClosestElement(element, '.panel'); if (!selectedPanel) { logger.error('Panel element not found'); logger.groupEnd(); return; } // Hide tooltips before fullscreen this.hideTooltips(selectedPanel); selectedPanel.classList.toggle('panel-fullscreen'); document.documentElement.classList.toggle('panel-fullscreen'); logger.log(`Panel fullscreen toggled: ${selectedPanel.id}`); logger.groupEnd(); }, close: function (element) { logger.group('Panel Close Action'); const selectedPanel = security.getClosestElement(element, '.panel'); if (!selectedPanel) { logger.error('Panel element not found'); logger.groupEnd(); return; } // Hide tooltips before showing close confirmation this.hideTooltips(selectedPanel); const panelTitle = selectedPanel.querySelector('.panel-hdr h2')?.textContent.trim() || 'Untitled Panel'; const killPanel = () => { selectedPanel.style.transition = 'opacity 0.5s'; selectedPanel.style.opacity = '0'; setTimeout(() => { selectedPanel.remove(); logger.log(`Panel removed: ${selectedPanel.id}`); }, 500); }; // Create modal if it doesn't exist let confirmModal = document.getElementById('panelDeleteModal'); if (!confirmModal) { const modalHTML = ` `; document.body.insertAdjacentHTML('beforeend', modalHTML); confirmModal = document.getElementById('panelDeleteModal'); } // Update modal content with current panel title const modalBody = confirmModal.querySelector('.modal-body'); //modalBody.textContent = `You are about to delete ${panelTitle}`; modalBody.innerHTML = `
You are about to delete ${panelTitle}. Are you sure you want to delete this panel?
`; // Initialize Bootstrap modal const modal = new bootstrap.Modal(confirmModal); // Set up delete confirmation handler const confirmDeleteBtn = confirmModal.querySelector('#confirmPanelDelete'); const deleteHandler = () => { //createUndoButton(); killPanel(); modal.hide(); confirmDeleteBtn.removeEventListener('click', deleteHandler); }; confirmDeleteBtn.addEventListener('click', deleteHandler); // Show modal modal.show(); // Clean up when modal is hidden confirmModal.addEventListener('hidden.bs.modal', () => { confirmDeleteBtn.removeEventListener('click', deleteHandler); }, { once: true }); logger.groupEnd(); }, style: function (element) { logger.group('Panel Style Action'); // First find the panel container from any location const panel = security.getClosestElement(element, '.panel'); if (!panel) { logger.error('Panel element not found'); logger.groupEnd(); return; } // Then find the header within the panel const selectedPanel = panel.querySelector('.panel-hdr'); if (!selectedPanel) { logger.error('Panel header not found'); logger.groupEnd(); return; } const styleAttr = element.getAttribute('data-panel-style'); if (!styleAttr) { logger.error('No style classes specified'); logger.groupEnd(); return; } // Split and sanitize each class const newStyles = styleAttr.split(' ') .map(style => security.sanitizeClassName(style.trim())) .filter(style => style.startsWith('bg-')); // Only accept bg-* classes if (newStyles.length === 0) { logger.error('No valid bg-* classes specified'); logger.groupEnd(); return; } // Remove existing bg-* classes const existingClasses = Array.from(selectedPanel.classList) .filter(className => className.startsWith('bg-')); existingClasses.forEach(className => { selectedPanel.classList.remove(className); }); // Add new bg classes newStyles.forEach(style => { selectedPanel.classList.add(style); logger.log(`Added panel style: ${style}`); }); // Save panel state savePanelState(); logger.groupEnd(); }, toggleClass: function (element) { logger.group('Panel Toggle Class Action'); // First find the panel container from any location const selectedPanel = security.getClosestElement(element, '.panel'); if (!selectedPanel) { logger.error('Panel element not found'); logger.groupEnd(); return; } const toggleClass = security.sanitizeClassName(element.getAttribute('data-panel-toggle')); if (!toggleClass) { logger.error('No toggle class specified'); logger.groupEnd(); return; } // Toggle class on the panel itself selectedPanel.classList.toggle(toggleClass); logger.log(`Panel class toggled: ${toggleClass}`); // Save panel state savePanelState(); logger.groupEnd(); }, reset: function (element) { logger.group('Panel Reset Action'); // First find the panel container from any location const selectedPanel = security.getClosestElement(element, '.panel'); if (!selectedPanel) { logger.error('Panel element not found'); logger.groupEnd(); return; } // Get all classes const panelClasses = Array.from(selectedPanel.classList); const headerElement = selectedPanel.querySelector('.panel-hdr'); // Remove all classes except the preserved ones from panel panelClasses.forEach(className => { if (!['panel', 'panel-collapsed', 'panel-fullscreen'].includes(className)) { selectedPanel.classList.remove(className); } }); // Reset header classes if it exists if (headerElement) { const headerClasses = Array.from(headerElement.classList); headerClasses.forEach(className => { if (className !== 'panel-hdr') { headerElement.classList.remove(className); } }); } logger.log('Panel reset to default state'); // Save panel state savePanelState(); logger.groupEnd(); }, refresh: function (element) { logger.group('Panel Refresh Action'); const selectedPanel = security.getClosestElement(element, '.panel'); if (!selectedPanel) { logger.error('Panel element not found'); logger.groupEnd(); return; } // Get refresh duration from data attribute or use default const refreshDuration = parseInt(element.getAttribute('data-refresh-duration')) || 1000; // Check if panel is already refreshing if (selectedPanel.classList.contains('panel-refreshing')) { logger.warn('Panel is already refreshing'); logger.groupEnd(); return; } // Add refreshing class selectedPanel.classList.add('panel-refreshing'); logger.log('Panel refresh started'); // Get callback function name from data attribute const callbackName = element.getAttribute('data-refresh-callback'); // Execute callback if it exists if (callbackName && typeof window[callbackName] === 'function') { try { window[callbackName](selectedPanel); logger.log(`Executed callback: ${callbackName}`); } catch (error) { logger.error(`Error in refresh callback: ${error.message}`); } } // Remove refreshing class after duration setTimeout(() => { selectedPanel.classList.remove('panel-refreshing'); logger.log('Panel refresh completed'); }, refreshDuration); logger.groupEnd(); } }, toggleTheme: function () { logger.group('Toggle Theme Action'); // Get current theme or default to 'light' const currentTheme = htmlRoot.getAttribute('data-bs-theme') || 'light'; const newTheme = currentTheme === 'light' ? 'dark' : 'light'; // Set the new theme htmlRoot.setAttribute('data-bs-theme', newTheme); logger.log(`Theme switched to: ${newTheme}`); // Use the utility method to update aria-pressed attributes plugin.updateThemeButtonsState(); // Save settings if available - do this immediately to save the data-bs-theme attribute if (typeof window.saveSettings === 'function') { window.saveSettings(); logger.log('Theme attribute settings saved'); } // Re-extract colors after theme change utils.extractColors(); logger.log('Colors re-extracted after theme change'); // Update all chart colors plugin.updateAllPluginColors(); logger.groupEnd(); }, removeClass: function (element) { const target = security.sanitizeSelector(element.getAttribute('data-target')); const className = security.sanitizeClassName(element.getAttribute('data-classname')); const targetElement = document.querySelector(target); logger.group('Remove Class Action'); if (targetElement?.classList.contains(className)) { targetElement.classList.remove(className); logger.log(`Removed class "${className}" from ${target}`); } if (target === 'html' && typeof window.saveSettings === 'function') { window.saveSettings(); plugin.checkActiveStyles(); logger.log('Settings saved'); } logger.groupEnd(); }, addClass: function (element) { const target = security.sanitizeSelector(element.getAttribute('data-target')); const className = security.sanitizeClassName(element.getAttribute('data-classname')); const targetElement = document.querySelector(target); logger.group('Add Class Action'); if (targetElement) { targetElement.classList.add(className); logger.log(`Added class "${className}" to ${target}`); } else { logger.error(`Target element not found: ${target}`); } if (target === 'html' && typeof window.saveSettings === 'function') { window.saveSettings(); plugin.checkActiveStyles(); logger.log('Settings saved'); } logger.groupEnd(); }, appFullscreen: function () { logger.group('Fullscreen Action'); if (!security.checkFullscreenPermission()) { logger.error('Fullscreen permission denied'); logger.groupEnd(); return; } if (!fullscreenHandler.isFullscreen()) { fullscreenHandler.enter() .then(() => { logger.log('Entered fullscreen mode'); document.body.classList.add('fullscreen-active'); localStorage.setItem('appFullscreen', 'true'); }) .catch(error => logger.error(`Fullscreen error: ${error.message}`)); } else { fullscreenHandler.exit() .then(() => { logger.log('Exited fullscreen mode'); document.body.classList.remove('fullscreen-active'); localStorage.removeItem('appFullscreen'); }) .catch(error => logger.error(`Fullscreen exit error: ${error.message}`)); } logger.groupEnd(); }, playSound: async function (element) { logger.group('Play Sound Action'); try { const soundFile = element.getAttribute('data-soundfile'); if (!soundFile) { throw new Error('No sound file specified'); } const sanitizedSound = audioHelpers.sanitizeFilename(soundFile); // Check if this sound is currently playing if (element.getAttribute('data-audio-playing') === sanitizedSound) { // If it's playing, pause it handlers.pauseSound(element); return; } // Stop all other playing sounds audioHelpers.stopAllSoundsExcept(sanitizedSound); const path = config.defaultSoundPath; // Try to get from cache first let audioElement = cache.audioCache.get(sanitizedSound); if (!audioElement) { audioElement = audioHelpers.createAudioElement( path, sanitizedSound, config.sound.volume ); cache.audioCache.set(sanitizedSound, audioElement); } // Reset the audio if it was previously played audioElement.currentTime = 0; // Apply fade-in if enabled if (config.sound.fadeIn) { await audioHelpers.fadeInAudio( audioElement, config.sound.volume, config.sound.fadeInDuration ); } await audioElement.play(); // Store the currently playing audio element in a data attribute element.setAttribute('data-audio-playing', sanitizedSound); logger.log(`Playing sound: ${path}${sanitizedSound}`); // Return promise that resolves when audio finishes playing return new Promise((resolve, reject) => { audioElement.addEventListener('ended', () => { element.removeAttribute('data-audio-playing'); resolve(); }, { once: true }); audioElement.addEventListener('error', (e) => reject(e), { once: true }); }); } catch (error) { logger.error(`Failed to play sound: ${error.message}`); throw error; } finally { logger.groupEnd(); } }, pauseSound: function (element) { logger.group('Pause Sound Action'); try { const playingSound = element.getAttribute('data-audio-playing'); if (!playingSound) { logger.log('No sound currently playing'); return; } const audioElement = cache.audioCache.get(playingSound); if (audioElement) { audioElement.pause(); audioElement.currentTime = 0; // Reset to beginning element.removeAttribute('data-audio-playing'); logger.log(`Paused sound: ${playingSound}`); } } catch (error) { logger.error(`Failed to pause sound: ${error.message}`); } finally { logger.groupEnd(); } }, themeStyle: function (element) { logger.group('Theme Style Action'); const themeUrl = element.getAttribute('data-theme-style'); if (themeUrl === null) { logger.error('Missing data-theme-style attribute'); logger.groupEnd(); return; } if (themeUrl === '') { // If empty, remove the theme style element const themeStyleElement = document.getElementById(config.theme.styleId); if (themeStyleElement) { themeStyleElement.remove(); logger.log('Theme style removed'); } } else { // Set the theme style let finalUrl = themeUrl; // If doesn't end with .css, add the extension if (!finalUrl.endsWith('.css')) { finalUrl = `css/themes/${finalUrl}.css`; } plugin.setThemeStyle(finalUrl); } // Update all theme switcher elements plugin.updateThemeStyleStates(); // Save settings if (typeof window.saveSettings === 'function') { window.saveSettings(); } logger.log(`Theme style changed to: ${themeUrl}`); logger.groupEnd(); } }; // Event delegation handler // Modify your handleAction function to include theme style action function handleAction(event) { const element = event.target.closest(config.selectors.actionButtons); if (!element) return; const actionType = security.validateDataAttribute(element.dataset.action); // Add panel actions to the handler object const handler = { 'toggle': () => handlers.toggle(element), 'toggle-replace': () => handlers.toggleReplace(element), 'toggle-swap': () => handlers.toggleSwap(element), 'remove-class': () => handlers.removeClass(element), 'add-class': () => handlers.addClass(element), 'app-fullscreen': () => handlers.appFullscreen(), 'playsound': () => handlers.playSound(element), 'pausesound': () => handlers.pauseSound(element), // Panel actions 'panel-collapse': () => handlers.panelActions.collapse(element), 'panel-fullscreen': () => handlers.panelActions.fullscreen(element), 'panel-close': () => handlers.panelActions.close(element), 'panel-style': () => handlers.panelActions.style(element), 'panel-toggle': () => handlers.panelActions.toggleClass(element), 'panel-reset': () => handlers.panelActions.reset(element), 'panel-refresh': () => handlers.panelActions.refresh(element), 'toggle-theme': () => handlers.toggleTheme(), 'theme-style': () => handlers.themeStyle(element) }[actionType]; if (handler) { handler(); logger.log(`Action executed: ${actionType}`); } else { logger.warn(`Unknown action type: ${actionType}`); } } // Add sound-specific methods to plugin plugin.preloadSound = function (soundFile) { const sanitizedSound = audioHelpers.sanitizeFilename(soundFile); if (!cache.audioCache.has(sanitizedSound)) { const audio = audioHelpers.createAudioElement( config.defaultSoundPath, sanitizedSound, config.sound.volume ); cache.audioCache.set(sanitizedSound, audio); } return plugin; }; // Configuration methods plugin.config = function (options) { Object.assign(config, options); logger.log('Configuration updated'); return plugin; }; plugin.debug = function (enabled) { config.debug = enabled; // Add or remove debug class on body element const debugClass = 'app-debug-mode'; if (enabled) { document.body.classList.add(debugClass); } else { document.body.classList.remove(debugClass); } logger.log(`Debug mode ${enabled ? 'enabled' : 'disabled'}`); return plugin; }; // Add programmatic methods for actions plugin.toggleTheme = function () { handlers.toggleTheme(); return plugin; }; // Add method to switch theme style programmatically plugin.switchThemeStyle = function (themeUrl) { if (!themeUrl) { logger.error('Theme URL is required'); return plugin; } plugin.setThemeStyle(themeUrl); plugin.updateThemeStyleStates(); return plugin; }; // Utility method to update aria-pressed on theme toggle buttons plugin.updateThemeButtonsState = function () { const currentTheme = htmlRoot.getAttribute('data-bs-theme') || 'light'; const isDarkTheme = currentTheme === 'dark'; document.querySelectorAll('[data-action="toggle-theme"]').forEach(button => { button.setAttribute('aria-pressed', isDarkTheme.toString()); logger.log(`Updated aria-pressed to ${isDarkTheme} on theme toggle button`); }); return plugin; }; plugin.toggleClass = function (target, className) { if (!target || !className) { logger.error('Target and className are required for toggleClass'); return plugin; } const element = document.createElement('button'); element.setAttribute('data-action', 'toggle'); element.setAttribute('data-class', className); element.setAttribute('data-target', target); handlers.toggle(element); return plugin; }; plugin.toggleReplace = function (target, removeClass, addClass) { if (!target || !addClass) { logger.error('Target and addClass are required for toggleReplace'); return plugin; } const element = document.createElement('button'); element.setAttribute('data-action', 'toggle-replace'); element.setAttribute('data-target', target); element.setAttribute('data-addclass', addClass); if (removeClass) { element.setAttribute('data-removeclass', removeClass); } handlers.toggleReplace(element); return plugin; }; plugin.toggleSwap = function (target, toggleClass) { if (!target || !toggleClass) { logger.error('Target and toggleClass are required for toggleSwap'); return plugin; } const element = document.createElement('button'); element.setAttribute('data-action', 'toggle-swap'); element.setAttribute('data-target', target); element.setAttribute('data-toggleclass', toggleClass); handlers.toggleSwap(element); return plugin; }; // Enhanced cleanup method plugin.destroy = function () { document.removeEventListener('click', handleAction); cache.actionButtons = null; // Clean up audio cache cache.audioCache.forEach(audio => { audio.pause(); audio.src = ''; }); cache.audioCache.clear(); logger.log('Plugin destroyed'); }; // Initialize plugin function init() { document.addEventListener('click', handleAction); document.addEventListener('DOMContentLoaded', function () { cache.actionButtons = document.querySelectorAll(config.selectors.actionButtons); plugin.checkActiveStyles(); // Extract colors after DOM is loaded utils.extractColors(); // Also update states immediately for elements already in the DOM plugin.updateThemeStyleStates(); plugin.updateThemeButtonsState(); }); logger.log('Plugin initialized'); } // Initialize the plugin init(); // Return public methods return plugin; })(); })(window); // Display the screen size in the top-right corner of the screen // document.addEventListener('DOMContentLoaded', function() { // appDOM.checkActiveStyles().debug(true); // }); // Sidebar menu scroll to active item window.addEventListener('load', () => { setTimeout(() => { const sideNavMenu = document.getElementById("js-nav-menu"); const activeItem = sideNavMenu?.querySelector('li.active a.active'); const scrollWrapper = document.querySelector('.app-sidebar .slimScrollDiv'); const scrollContainer = scrollWrapper?.firstElementChild; if (activeItem && scrollContainer) { const containerRect = scrollContainer.getBoundingClientRect(); const itemRect = activeItem.getBoundingClientRect(); const offset = scrollContainer.scrollTop + (itemRect.bottom - containerRect.top) - 400; if (offset > 70) { smoothScrollTo(scrollContainer, offset, 30); } } }, 50); }); function smoothScrollTo(element, to, duration) { const start = element.scrollTop; const change = to - start; const increment = 20; let currentTime = 0; function animateScroll() { currentTime += increment; const val = easeInOutQuad(currentTime, start, change, duration); element.scrollTop = val; if (currentTime < duration) { setTimeout(animateScroll, increment); } } animateScroll(); } function easeInOutQuad(t, b, c, d) { t /= d / 2; if (t < 1) return c / 2 * t * t + b; t--; return -c / 2 * (t * (t - 2) - 1) + b; }