40cffb3beb
TaxBaik CI/CD / build-and-deploy (push) Successful in 2m26s
Problem: JavaScript login form saved tokens to localStorage but didn't notify CustomAuthenticationStateProvider, causing [Authorize] pages to remain in 'loading' state indefinitely. The provider only reads tokens when: 1. GetAuthenticationStateAsync() is called (page load) 2. NotifyAuthenticationStateChanged() is triggered (UI updates) But JavaScript login didn't trigger either, leaving the authentication state stale. Solution: Convert AdminLoginForm from HTML+JavaScript to pure Blazor component. Now the login flow is: 1. User enters credentials in Blazor form 2. HttpClient POST to /api/auth/login 3. Save tokens to localStorage 4. Call CustomAuthenticationStateProvider.LoginAsync() directly 5. Blazor detects auth state change and re-evaluates [Authorize] pages 6. Dashboard [Authorize] page renders successfully Result: Immediate authentication state update, no loading timeout on protected pages. Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
1830 lines
74 KiB
JavaScript
1830 lines
74 KiB
JavaScript
|
|
|
|
|
|
|
|
'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: '<i class="sa sa-chevron-down"></i>',
|
|
openedSign: '<i class="sa sa-chevron-up"></i>',
|
|
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 = `
|
|
<div class="modal fade" id="panelDeleteModal" tabindex="-1" aria-hidden="true" style="--bs-modal-width: 450px;">
|
|
<div class="modal-dialog modal-dialog-centered">
|
|
<div class="modal-content bg-dark bg-opacity-50 shadow-5 translucent-dark">
|
|
<div class="modal-header border-bottom-0">
|
|
<h4 class="modal-title text-white d-flex align-items-center">
|
|
Delete Panel?
|
|
</h4>
|
|
<button type="button" class="btn btn-system btn-system-light ms-auto" data-bs-dismiss="modal" aria-label="Close">
|
|
<svg class="sa-icon sa-icon-2x">
|
|
<use href="img/sprite.svg#x"></use>
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
<div class="modal-body"></div>
|
|
<div class="modal-footer border-top-0">
|
|
<button type="button" class="btn btn-light" data-bs-dismiss="modal">No, cancel</button>
|
|
<button type="button" class="btn btn-danger" id="confirmPanelDelete">Yes, delete</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>`;
|
|
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 <strong>${panelTitle}</strong>`;
|
|
modalBody.innerHTML = `
|
|
<div class="alert alert-danger bg-danger border-danger text-white border-opacity-50 bg-opacity-10 mb-0">
|
|
You are about to delete <span class="fw-700">${panelTitle}.</span>
|
|
Are you sure you want to delete this panel?
|
|
</div>`;
|
|
|
|
// 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;
|
|
} |