Files
OTSSignsTheme/custom/otssignange/views/theme-scripts.twig

1222 lines
44 KiB
Twig

/**
* OTS Signage Modern Theme - Client-Side Utilities
* Sidebar toggle, dropdown menus, and UI interactions
*/
(function() {
'use strict';
// Apply saved or system-preferred theme as early as possible to avoid
// a flash from dark -> light when navigating between pages.
(function() {
try {
var stored = localStorage.getItem('ots-theme-mode');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var initial = stored || (prefersLight ? 'light' : 'light');
if (initial === 'light') {
document.documentElement.classList.add('ots-light-mode');
if (document.body) document.body.classList.add('ots-light-mode');
} else {
document.documentElement.classList.remove('ots-light-mode');
if (document.body) document.body.classList.remove('ots-light-mode');
}
} catch (err) {
// ignore failures (e.g. localStorage unavailable)
}
})();
const STORAGE_KEYS = {
sidebarCollapsed: 'otsTheme:sidebarCollapsed'
};
/**
* Sidebar width is now handled purely by CSS classes (.ots-sidebar-collapsed).
* This function is kept as a no-op for backward compatibility.
*/
function updateSidebarWidth() {
// No-op: CSS handles layout via body.ots-sidebar-collapsed class
if (window.__otsDebug) {
const sidebar = document.querySelector('.ots-sidebar');
const collapsed = sidebar ? sidebar.classList.contains('collapsed') : false;
console.log('[OTS] updateSidebarWidth (no-op, CSS-driven)', { collapsed });
}
}
/**
* Measure the sidebar header bottom and set the top padding of the nav list
* so nav items always begin below the header (logo + buttons).
*/
function updateSidebarNavOffset() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const header = sidebar.querySelector('.sidebar-header');
const nav = sidebar.querySelector('.sidebar-nav, .ots-sidebar-nav');
if (!nav) return;
const sidebarRect = sidebar.getBoundingClientRect();
const headerRect = header ? header.getBoundingClientRect() : null;
let offset = 0;
if (headerRect) {
offset = Math.max(0, Math.ceil(headerRect.bottom - sidebarRect.top));
} else if (header) {
offset = header.offsetHeight || 0;
}
const gap = 8;
const paddingTop = offset > 0 ? offset + gap : '';
if (paddingTop) {
try {
nav.style.setProperty('padding-top', `${paddingTop}px`, 'important');
} catch (err) {
nav.style.paddingTop = `${paddingTop}px`;
}
if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset applied', { paddingTop });
} else {
try {
nav.style.removeProperty('padding-top');
} catch (err) {
nav.style.paddingTop = '';
}
if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset cleared');
}
}
/**
* DISABLED: Cleanup function to remove inline styles that were forcing incorrect margins
* The sidebar layout is now controlled entirely by CSS variables and margin-left.
*/
function updateSidebarGap() {
// This function is intentionally left minimal.
// Spacing is now handled by CSS: .ots-main { margin-left: var(--ots-sidebar-width) }
// Removing any inline margin-left or padding-left that may have been set previously
const targets = [
document.getElementById('page-wrapper'),
document.querySelector('.ots-main'),
document.getElementById('content-wrapper'),
document.querySelector('#content')
].filter(Boolean);
targets.forEach(pageWrapper => {
try {
pageWrapper.style.removeProperty('margin-left');
pageWrapper.style.removeProperty('padding-left');
} catch (err) {
pageWrapper.style.marginLeft = '';
pageWrapper.style.paddingLeft = '';
}
// Also remove from common child wrappers
try {
const inner = pageWrapper.querySelector('.page-content') || pageWrapper.querySelector('.ots-content') || pageWrapper.querySelector('.container');
if (inner) {
inner.style.removeProperty('padding-left');
}
} catch (err) {}
});
}
function debounce(fn, wait) {
let t;
return function () {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, arguments), wait);
};
}
/**
* Reflect sidebar open/collapsed state on the document body
*/
function updateSidebarStateClass() {
const sidebar = document.querySelector('.ots-sidebar');
if (!sidebar) return;
const body = document.body;
const isCollapsed = sidebar.classList.contains('collapsed');
if (!isCollapsed) {
body.classList.add('ots-sidebar-open');
} else {
body.classList.remove('ots-sidebar-open');
}
}
/**
* Initialize sidebar toggle functionality
*/
function initSidebarToggle() {
const toggleBtn = document.querySelector('[data-action="toggle-sidebar"]');
const sidebar = document.querySelector('.ots-sidebar');
const collapseBtn = document.querySelector('.sidebar-collapse-btn-visible');
const expandBtn = document.querySelector('.sidebar-expand-btn');
const body = document.body;
if (!sidebar) return;
// Mobile-aware toggle: add backdrop, aria-expanded, and focus management
if (toggleBtn) {
let lastFocus = null;
function ensureBackdrop() {
let bd = document.querySelector('.ots-backdrop');
if (!bd) {
bd = document.createElement('div');
bd.className = 'ots-backdrop';
bd.addEventListener('click', function() {
sidebar.classList.remove('active');
bd.classList.remove('show');
toggleBtn.setAttribute('aria-expanded', 'false');
if (lastFocus) lastFocus.focus();
});
document.body.appendChild(bd);
}
return bd;
}
toggleBtn.setAttribute('role', 'button');
toggleBtn.setAttribute('aria-controls', 'ots-sidebar');
toggleBtn.setAttribute('aria-expanded', 'false');
toggleBtn.addEventListener('click', function(e) {
e.preventDefault();
const isNowActive = !sidebar.classList.contains('active');
sidebar.classList.toggle('active');
// On small screens show backdrop and manage focus
if (window.innerWidth <= 768) {
const bd = ensureBackdrop();
if (isNowActive) {
bd.classList.add('show');
toggleBtn.setAttribute('aria-expanded', 'true');
lastFocus = document.activeElement;
const firstFocusable = sidebar.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) firstFocusable.focus(); else { sidebar.setAttribute('tabindex', '-1'); sidebar.focus(); }
document.addEventListener('keydown', escHandler);
} else {
bd.classList.remove('show');
toggleBtn.setAttribute('aria-expanded', 'false');
if (lastFocus) lastFocus.focus();
document.removeEventListener('keydown', escHandler);
}
}
updateSidebarStateClass();
});
function escHandler(e) {
if (e.key === 'Escape' || e.key === 'Esc') {
const bd = document.querySelector('.ots-backdrop');
if (sidebar.classList.contains('active')) {
sidebar.classList.remove('active');
if (bd) bd.classList.remove('show');
toggleBtn.setAttribute('aria-expanded', 'false');
if (lastFocus) lastFocus.focus();
document.removeEventListener('keydown', escHandler);
}
}
}
}
if (collapseBtn) {
const isCollapsed = localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true';
if (isCollapsed) {
sidebar.classList.add('collapsed');
body.classList.add('ots-sidebar-collapsed');
document.documentElement.classList.add('ots-sidebar-collapsed');
updateSidebarStateClass();
// updateSidebarGap() disabled - use CSS variables instead
}
collapseBtn.addEventListener('click', function(e) {
e.preventDefault();
const nowCollapsed = !sidebar.classList.contains('collapsed');
sidebar.classList.toggle('collapsed');
body.classList.toggle('ots-sidebar-collapsed', nowCollapsed);
document.documentElement.classList.toggle('ots-sidebar-collapsed', nowCollapsed);
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, nowCollapsed ? 'true' : 'false');
updateSidebarNavOffset();
updateSidebarStateClass();
});
}
if (expandBtn) {
expandBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.remove('collapsed');
body.classList.remove('ots-sidebar-collapsed');
document.documentElement.classList.remove('ots-sidebar-collapsed');
localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false');
updateSidebarNavOffset();
updateSidebarStateClass();
});
}
// Close sidebar when clicking outside on mobile
document.addEventListener('click', function(e) {
if (window.innerWidth <= 768) {
const isClickInsideSidebar = sidebar.contains(e.target);
const isClickOnToggle = toggleBtn && toggleBtn.contains(e.target);
if (!isClickInsideSidebar && !isClickOnToggle && sidebar.classList.contains('active')) {
sidebar.classList.remove('active');
updateSidebarStateClass();
}
}
});
// Ensure initial state class is set
updateSidebarStateClass();
}
/**
* Initialize sidebar section collapse/expand functionality
*/
function initSidebarSectionToggles() {
const groupToggles = document.querySelectorAll('.sidebar-group-toggle');
groupToggles.forEach(toggle => {
const group = toggle.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
const caret = toggle.querySelector('.sidebar-group-caret');
if (submenu) {
const isOpen = group.classList.contains('is-open');
submenu.style.display = isOpen ? 'block' : 'none';
toggle.setAttribute('aria-expanded', isOpen.toString());
}
toggle.addEventListener('click', function(e) {
e.preventDefault();
const group = toggle.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const isOpen = group.classList.contains('is-open');
group.classList.toggle('is-open', !isOpen);
toggle.setAttribute('aria-expanded', (!isOpen).toString());
submenu.style.display = isOpen ? 'none' : 'block';
});
if (caret) {
caret.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggle.click();
});
}
});
// Capture-phase handler to override any conflicting listeners
document.addEventListener('click', function(e) {
const caret = e.target.closest('.sidebar-group-caret');
const toggle = e.target.closest('.sidebar-group-toggle');
const target = toggle || (caret ? caret.closest('.sidebar-group-toggle') : null);
if (!target) return;
e.preventDefault();
e.stopPropagation();
const group = target.closest('.sidebar-group');
const submenu = group ? group.querySelector('.sidebar-submenu') : null;
if (!submenu) return;
const isOpen = group.classList.contains('is-open');
group.classList.toggle('is-open', !isOpen);
target.setAttribute('aria-expanded', (!isOpen).toString());
submenu.style.display = isOpen ? 'none' : 'block';
}, true);
}
/**
* Initialize dropdown menus
*/
function initDropdowns() {
const dropdowns = document.querySelectorAll('.dropdown');
dropdowns.forEach(dropdown => {
const button = dropdown.querySelector('.dropdown-menu');
if (!button) return;
const menu = dropdown.querySelector('.dropdown-menu');
// Toggle menu on button click
dropdown.addEventListener('click', function(e) {
if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) {
e.preventDefault();
const nowActive = !dropdown.classList.contains('active');
dropdown.classList.toggle('active');
// If the dropdown has a menu, float it out of any overflowed container
try {
const ddMenu = dropdown.querySelector('.dropdown-menu');
if (ddMenu) {
if (nowActive) {
floatMenu(ddMenu, dropdown);
} else {
unfloatMenu(ddMenu);
}
}
} catch (err) { /* ignore */ }
// If this dropdown contains the user menu, compute placement to avoid going off-screen
const menu = dropdown.querySelector('.dropdown-menu.ots-user-menu');
const trigger = dropdown.querySelector('#navbarUserMenu');
if (menu && trigger) {
// Reset any previous placement classes
menu.classList.remove('dropdown-menu-left');
menu.classList.remove('dropdown-menu-right');
// Use getBoundingClientRect for accurate placement
const trigRect = trigger.getBoundingClientRect();
// Ensure menu is in DOM and has an offsetWidth
const menuWidth = menu.offsetWidth || 220; // fallback estimate
const spaceRight = window.innerWidth - trigRect.right;
const spaceLeft = trigRect.left;
// Prefer opening to the right where possible, otherwise open to the left
if (spaceRight < menuWidth && spaceLeft > menuWidth) {
// not enough space on the right, open to left
menu.classList.add('dropdown-menu-left');
} else {
// default to right-aligned
menu.classList.add('dropdown-menu-right');
}
}
}
});
// Close menu when clicking outside
document.addEventListener('click', function(e) {
if (!dropdown.contains(e.target)) {
const hasActive = dropdown.classList.contains('active');
dropdown.classList.remove('active');
if (hasActive) {
try { const ddMenu = dropdown.querySelector('.dropdown-menu'); if (ddMenu) unfloatMenu(ddMenu); } catch (err) {}
}
}
});
});
}
/**
* Float a menu element into document.body so it can escape overflowed parents.
* Adds `.ots-floating-menu` and positions absolutely based on the trigger rect.
*/
function floatMenu(menuEl, triggerEl) {
if (!menuEl || !triggerEl || menuEl.getAttribute('data-ots-floating') === '1') return;
try {
// Remember original parent and next sibling so we can restore later
menuEl._otsOriginalParent = menuEl.parentNode || null;
menuEl._otsOriginalNext = menuEl.nextSibling || null;
menuEl.setAttribute('data-ots-floating', '1');
menuEl.classList.add('ots-floating-menu');
// Append to body
document.body.appendChild(menuEl);
const rect = triggerEl.getBoundingClientRect();
// Default placement below trigger, align to left edge
const top = Math.max(8, Math.round(rect.bottom + window.scrollY + 6));
const left = Math.max(8, Math.round(rect.left + window.scrollX));
// Use fixed positioning so the menu floats above all stacking contexts
// Use fixed positioning so the menu floats above all stacking contexts
try {
menuEl.style.setProperty('position', 'fixed', 'important');
menuEl.style.setProperty('top', Math.max(6, Math.round(rect.bottom + 6)) + 'px', 'important');
menuEl.style.setProperty('left', left + 'px', 'important');
// Use the maximum reasonable z-index to ensure it appears on top
menuEl.style.setProperty('z-index', '2147483647', 'important');
// Ensure transforms won't clip rendering
menuEl.style.setProperty('transform', 'none', 'important');
menuEl.style.setProperty('min-width', (rect.width) + 'px', 'important');
menuEl.style.setProperty('pointer-events', 'auto', 'important');
} catch (err) {
// fallback to non-important inline style
menuEl.style.position = 'fixed';
menuEl.style.top = Math.max(6, Math.round(rect.bottom + 6)) + 'px';
menuEl.style.left = left + 'px';
menuEl.style.zIndex = '2147483647';
menuEl.style.transform = 'none';
menuEl.style.minWidth = (rect.width) + 'px';
menuEl.style.pointerEvents = 'auto';
}
// Reposition on scroll/resize while open
const reposition = function() {
if (menuEl.getAttribute('data-ots-floating') !== '1') return;
const r = triggerEl.getBoundingClientRect();
// For fixed positioning we only need viewport coords
menuEl.style.top = Math.max(6, Math.round(r.bottom + 6)) + 'px';
menuEl.style.left = Math.max(6, Math.round(r.left)) + 'px';
};
menuEl._otsReposition = reposition;
window.addEventListener('scroll', reposition, true);
window.addEventListener('resize', reposition);
// Guard: some libraries move/drop menus. Keep a short-lived guard that
// re-attaches the menu to body and re-applies important styles while open.
let guardCount = 0;
const guard = setInterval(function() {
try {
if (menuEl.getAttribute('data-ots-floating') !== '1') {
clearInterval(guard);
return;
}
// If parent moved, re-append to body
if (menuEl.parentNode !== document.body) document.body.appendChild(menuEl);
// Re-ensure important styles
menuEl.style.setProperty('z-index', '2147483647', 'important');
menuEl.style.setProperty('position', 'fixed', 'important');
} catch (err) {}
guardCount += 1;
if (guardCount > 120) {
clearInterval(guard);
}
}, 100);
menuEl._otsGuard = guard;
} catch (err) {
console.warn('[OTS] floatMenu failed', err);
}
}
function unfloatMenu(menuEl) {
if (!menuEl || menuEl.getAttribute('data-ots-floating') !== '1') return;
try {
menuEl.removeAttribute('data-ots-floating');
menuEl.classList.remove('ots-floating-menu');
menuEl.style.position = '';
menuEl.style.top = '';
menuEl.style.left = '';
menuEl.style.zIndex = '';
menuEl.style.minWidth = '';
menuEl.style.pointerEvents = '';
// Remove reposition listeners
if (menuEl._otsReposition) {
window.removeEventListener('scroll', menuEl._otsReposition, true);
window.removeEventListener('resize', menuEl._otsReposition);
delete menuEl._otsReposition;
}
// Attempt to restore the original parent and insertion point
try {
if (menuEl._otsOriginalParent) {
if (menuEl._otsOriginalNext && menuEl._otsOriginalNext.parentNode === menuEl._otsOriginalParent) {
menuEl._otsOriginalParent.insertBefore(menuEl, menuEl._otsOriginalNext);
} else {
menuEl._otsOriginalParent.appendChild(menuEl);
}
delete menuEl._otsOriginalParent;
delete menuEl._otsOriginalNext;
} else {
// fallback: append to body (leave it there)
document.body.appendChild(menuEl);
}
} catch (err) {
document.body.appendChild(menuEl);
}
} catch (err) {
console.warn('[OTS] unfloatMenu failed', err);
}
}
/**
* Observe document for dynamically added dropdown menus and float them when necessary.
*/
function observeAndFloatMenus() {
try {
const mo = new MutationObserver(function(muts) {
muts.forEach(function(m) {
(m.addedNodes || []).forEach(function(node) {
try {
if (!node || node.nodeType !== 1) return;
// If the node itself is a dropdown menu
if (node.classList && node.classList.contains('dropdown-menu')) {
attachIfNeeded(node);
}
// Or contains dropdown menus
const menus = node.querySelectorAll && node.querySelectorAll('.dropdown-menu');
if (menus && menus.length) {
menus.forEach(attachIfNeeded);
}
} catch (err) {}
});
});
});
mo.observe(document.body, { childList: true, subtree: true });
// keep alive for the lifetime of the page
} catch (err) {
// ignore
}
function attachIfNeeded(menu) {
try {
if (!menu || menu.getAttribute('data-ots-floating-obs') === '1') return;
menu.setAttribute('data-ots-floating-obs', '1');
// find a reasonable trigger element: aria-labelledby or previous element
let trigger = null;
const labelled = menu.getAttribute('aria-labelledby');
if (labelled) trigger = document.getElementById(labelled);
if (!trigger) trigger = menu._otsOriginalParent ? menu._otsOriginalParent.querySelector('[data-toggle="dropdown"]') : null;
if (!trigger) trigger = menu.previousElementSibling || null;
// If the menu is visible and inside an overflowed ancestor, float it
const rect = menu.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) return; // not rendered yet
if (isClippedByOverflow(menu) && trigger) {
floatMenu(menu, trigger);
}
// Also watch for when dropdown gets toggled active via class
const obs = new MutationObserver(function(ms) {
ms.forEach(function(mm) {
if (mm.type === 'attributes' && mm.attributeName === 'class') {
const isActive = menu.classList.contains('show') || menu.parentNode && menu.parentNode.classList.contains('active');
if (isActive && trigger) floatMenu(menu, trigger);
if (!isActive) unfloatMenu(menu);
}
});
});
obs.observe(menu, { attributes: true, attributeFilter: ['class'] });
} catch (err) {}
}
function isClippedByOverflow(el) {
let p = el.parentElement;
while (p && p !== document.body) {
const s = window.getComputedStyle(p);
if (/(hidden|auto|scroll)/.test(s.overflow + s.overflowY + s.overflowX)) {
const r = el.getBoundingClientRect();
const pr = p.getBoundingClientRect();
// if element overflows parent's rect then it's clipped
if (r.bottom > pr.bottom || r.top < pr.top || r.left < pr.left || r.right > pr.right) return true;
}
p = p.parentElement;
}
return false;
}
}
/**
* Force common menu classes to the top by moving them to body and keeping them there.
* This is the most aggressive approach to ensure menus are never clipped.
*/
function forceTopMenus() {
const selectors = ['.dropdown-menu', '.ots-notif-menu', '.ots-user-menu', '.context-menu', '.row-menu', '.rowMenu', '.menu-popover'];
function moveToBody(el) {
try {
if (!el || el.getAttribute('data-ots-moved-to-body') === '1') return;
// Store original parent info
el._otsOriginalParent = el.parentElement;
el._otsOriginalNextSibling = el.nextElementSibling;
el.setAttribute('data-ots-moved-to-body', '1');
// Force fixed positioning with maximum z-index
el.style.setProperty('position', 'fixed', 'important');
el.style.setProperty('z-index', '2147483647', 'important');
el.style.setProperty('transform', 'none', 'important');
el.style.setProperty('pointer-events', 'auto', 'important');
el.style.setProperty('visibility', 'visible', 'important');
el.style.setProperty('display', 'block', 'important');
el.style.setProperty('opacity', '1', 'important');
el.style.setProperty('clip-path', 'none', 'important');
// Move to body if not already there
if (el.parentElement !== document.body) {
document.body.appendChild(el);
}
} catch (err) {
console.warn('[OTS] moveToBody failed', err);
}
}
function applyMenuStyles(el) {
try {
if (!el) return;
el.style.setProperty('position', 'fixed', 'important');
el.style.setProperty('z-index', '2147483647', 'important');
el.style.setProperty('transform', 'none', 'important');
el.style.setProperty('pointer-events', 'auto', 'important');
} catch (err) {}
}
// Apply to existing menus immediately
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
moveToBody(el);
applyMenuStyles(el);
});
});
// Continuously guard: check that menus stay in body and have correct styles
let guardInterval = setInterval(function() {
try {
selectors.forEach(sel => {
document.querySelectorAll(sel).forEach(el => {
// If menu got moved back, move it to body again
if (el.parentElement !== document.body && el.parentElement !== null) {
document.body.appendChild(el);
}
// Reapply critical styles in case they got overridden
applyMenuStyles(el);
});
});
} catch (err) {}
}, 200);
// Keep guard alive for the page lifetime, but stop if no menus found after 30s
let noMenuCount = 0;
const checkGuard = setInterval(function() {
const hasMenus = selectors.some(sel => document.querySelector(sel));
if (!hasMenus) {
noMenuCount++;
if (noMenuCount > 150) {
clearInterval(guardInterval);
clearInterval(checkGuard);
}
} else {
noMenuCount = 0;
}
}, 200);
// Observe for dynamically added menus
try {
const mo = new MutationObserver(function(muts) {
muts.forEach(m => {
(m.addedNodes || []).forEach(node => {
try {
if (!node || node.nodeType !== 1) return;
selectors.forEach(sel => {
if (node.matches && node.matches(sel)) {
moveToBody(node);
}
const found = node.querySelectorAll && node.querySelectorAll(sel);
found && found.forEach(moveToBody);
});
} catch (err) {}
});
});
});
mo.observe(document.body, { childList: true, subtree: true });
} catch (err) {}
}
/**
* Initialize search functionality
*/
function initSearch() {
const searchForm = document.querySelector('.topbar-search');
if (!searchForm) return;
const input = searchForm.querySelector('.search-input');
if (input) {
input.addEventListener('focus', function() {
searchForm.style.borderColor = 'var(--color-primary)';
});
input.addEventListener('blur', function() {
searchForm.style.borderColor = 'var(--color-border)';
});
}
}
/**
* Initialize page specific interactions
*/
function initPageInteractions() {
// Displays page - folder selection
const folderItems = document.querySelectorAll('.folder-item');
folderItems.forEach(item => {
item.addEventListener('click', function() {
folderItems.forEach(f => f.classList.remove('active'));
this.classList.add('active');
});
});
// Filter collapse toggle
const filterCollapseBtn = document.querySelector('#ots-filter-collapse-btn');
const filterContent = document.querySelector('#ots-filter-content');
if (filterCollapseBtn && filterContent) {
const storageKey = `ots-filter-collapsed:${window.location.pathname}`;
let isCollapsed = false;
filterCollapseBtn.addEventListener('click', function() {
isCollapsed = !isCollapsed;
filterContent.classList.toggle('collapsed', isCollapsed);
// Rotate icon
const icon = filterCollapseBtn.querySelector('i');
icon.classList.toggle('fa-chevron-up');
icon.classList.toggle('fa-chevron-down');
// Save preference to localStorage
localStorage.setItem(storageKey, isCollapsed);
});
// Restore saved preference
const savedState = localStorage.getItem(storageKey);
if (savedState === 'true') {
isCollapsed = true;
filterContent.classList.add('collapsed');
const icon = filterCollapseBtn.querySelector('i');
icon.classList.remove('fa-chevron-up');
icon.classList.add('fa-chevron-down');
} else {
filterContent.classList.remove('collapsed');
}
}
// Displays page - folder tree toggle layout
const folderToggleBtn = document.querySelector('#folder-tree-select-folder-button');
const folderContainer = document.querySelector('.grid-with-folders-container');
const folderTree = document.querySelector('#grid-folder-filter');
if (folderToggleBtn && folderContainer && folderTree) {
let debounceTimeout;
const syncFolderLayout = () => {
// Check actual visibility using computed styles
const computedStyle = window.getComputedStyle(folderTree);
const isHidden = computedStyle.display === 'none' ||
computedStyle.visibility === 'hidden' ||
folderTree.offsetHeight === 0;
console.log('Folder collapse sync:', {
isHidden,
display: computedStyle.display,
visibility: computedStyle.visibility,
offsetHeight: folderTree.offsetHeight
});
folderContainer.classList.toggle('ots-folder-collapsed', !!isHidden);
// Log the result
console.log('Container classes:', folderContainer.className);
console.log('Grid template columns:', window.getComputedStyle(folderContainer).gridTemplateColumns);
// Force reflow
folderContainer.offsetHeight;
};
const debouncedSync = () => {
clearTimeout(debounceTimeout);
debounceTimeout = setTimeout(syncFolderLayout, 50);
};
// Watch for style/class changes on folderTree (let Xibo's code run first)
const treeObserver = new MutationObserver(() => {
console.log('Folder tree mutation detected, debouncing sync...');
debouncedSync();
});
treeObserver.observe(folderTree, {
attributes: true,
attributeFilter: ['style', 'class']
});
// Initial sync
syncFolderLayout();
// Monitor the folder tree's parent for display changes
const parentObserver = new MutationObserver(debouncedSync);
const treeParent = folderTree.parentElement;
if (treeParent) {
parentObserver.observe(treeParent, {
childList: false,
attributes: true,
subtree: false
});
}
}
// Media page - item selection
const mediaItems = document.querySelectorAll('.media-item');
mediaItems.forEach(item => {
item.addEventListener('click', function() {
this.style.opacity = '0.7';
setTimeout(() => this.style.opacity = '1', 200);
});
});
}
/**
* Make sidebar responsive
*/
function makeResponsive() {
const sidebar = document.querySelector('.ots-sidebar');
const main = document.querySelector('.ots-main');
if (!sidebar) return;
// Add toggle button for mobile
if (window.innerWidth <= 768) {
sidebar.classList.add('mobile');
}
window.addEventListener('resize', function() {
if (window.innerWidth > 768) {
sidebar.classList.remove('mobile', 'active');
} else {
sidebar.classList.add('mobile');
}
// updateSidebarGap() disabled - use CSS variables instead
});
}
/**
* Prevent Chart.js errors when chart elements are missing
*/
function initChartSafeguard() {
if (!window.Chart) return;
if (typeof window.Chart.acquireContext === 'function') {
window.Chart.acquireContext = function(item) {
if (!item) return null;
const candidate = item.length ? item[0] : item;
if (candidate && typeof candidate.getContext === 'function') {
return candidate.getContext('2d');
}
return null;
};
return;
}
if (window.Chart.prototype && typeof window.Chart.prototype.acquireContext === 'function') {
window.Chart.prototype.acquireContext = function(item) {
if (!item) return null;
const candidate = item.length ? item[0] : item;
if (candidate && typeof candidate.getContext === 'function') {
return candidate.getContext('2d');
}
return null;
};
}
}
/**
* Enhance tables: wrap in card, add per-table search box, client-side filtering
* Non-destructive: skips tables already enhanced
*/
function enhanceTables() {
const selector = '.ots-content table, .content table, .container table, .card table, table';
const tables = Array.from(document.querySelectorAll(selector));
let counter = 0;
tables.forEach(table => {
// only enhance tables that have a thead and tbody
if (!table || table.classList.contains('modern-table')) return;
if (!table.querySelector('thead') || !table.querySelector('tbody')) return;
counter += 1;
table.classList.add('modern-table');
// Build wrapper structure
const wrapper = document.createElement('div');
wrapper.className = 'modern-table-card';
const controls = document.createElement('div');
controls.className = 'table-controls';
const input = document.createElement('input');
input.type = 'search';
input.placeholder = 'Search…';
input.className = 'table-search-input';
input.setAttribute('aria-label', 'Table search');
controls.appendChild(input);
const tableWrapper = document.createElement('div');
tableWrapper.className = 'table-wrapper';
tableWrapper.style.overflow = 'auto';
// Insert wrapper into DOM in place of the table
const parent = table.parentNode;
parent.replaceChild(wrapper, table);
wrapper.appendChild(controls);
wrapper.appendChild(tableWrapper);
tableWrapper.appendChild(table);
// Simple, light-weight search filtering for this table only
input.addEventListener('input', function (e) {
const term = (e.target.value || '').toLowerCase();
table.querySelectorAll('tbody tr').forEach(tr => {
const text = tr.textContent.toLowerCase();
tr.style.display = term === '' || text.includes(term) ? '' : 'none';
});
});
});
}
/**
* Initialize DataTables for enhanced behavior when available.
* Falls back gracefully if DataTables or jQuery are not present.
*/
function initDataTables() {
if (!window.jQuery) return;
const $ = window.jQuery;
if (!$.fn || !$.fn.dataTable) return;
// Skip Xibo-managed grids to avoid double initialization
if (document.querySelector('.XiboGrid')) return;
$('.modern-table, table').each(function () {
try {
if (this.closest('.XiboGrid')) return;
if (!$.fn.dataTable.isDataTable(this)) {
$(this).DataTable({
responsive: true,
lengthChange: false,
pageLength: 10,
autoWidth: false,
dom: '<"table-controls"f>rt<"table-meta"ip>',
language: { search: '' }
});
}
} catch (err) {
// If initialization fails, ignore and allow fallback enhancer
console.warn('DataTables init failed for table', this, err);
}
});
}
/**
* Initialize light/dark mode toggle
*/
function initThemeToggle() {
const themeToggle = document.getElementById('ots-theme-toggle');
if (!themeToggle) return;
const storedTheme = localStorage.getItem('ots-theme-mode');
const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
const effectiveTheme = storedTheme || (prefersLight ? 'light' : 'dark');
const body = document.body;
const root = document.documentElement;
// Apply stored theme on page load (apply to both <html> and <body>)
if (effectiveTheme === 'light') {
body.classList.add('ots-light-mode');
root.classList.add('ots-light-mode');
updateThemeLabel();
}
// Toggle on click (keep <html> in sync so :root variables reflect mode)
themeToggle.addEventListener('click', function(e) {
e.preventDefault();
const isLight = body.classList.toggle('ots-light-mode');
root.classList.toggle('ots-light-mode', isLight);
localStorage.setItem('ots-theme-mode', isLight ? 'light' : 'dark');
updateThemeLabel();
});
function updateThemeLabel() {
const icon = document.getElementById('ots-theme-icon');
const label = document.getElementById('ots-theme-label');
const isLight = body.classList.contains('ots-light-mode');
if (icon) {
icon.className = isLight ? 'fa fa-sun-o' : 'fa fa-moon-o';
}
if (label) {
label.textContent = isLight ? 'Light Mode' : 'Dark Mode';
}
}
}
/**
* Move DataTable row dropdown menus to <body> so they escape
* any overflow:hidden / overflow:auto ancestor containers.
*
* Xibo renders the row action button as:
* <div class="btn-group pull-right dropdown-menu-container">
* <button class="btn btn-white dropdown-toggle" data-toggle="dropdown">
* <div class="dropdown-menu dropdown-menu-right">...items...</div>
* </div>
*
* Bootstrap fires shown/hide.bs.dropdown on the toggle's parent element
* (the .btn-group). We listen on document with a selector that matches
* the actual Xibo markup.
*/
function initRowDropdowns() {
var DROPDOWN_PARENT_SEL = '.dropdown-menu-container, .btn-group';
var SCOPE_SEL = '.XiboData ' + DROPDOWN_PARENT_SEL
+ ', .XiboGrid ' + DROPDOWN_PARENT_SEL
+ ', #datatable-container ' + DROPDOWN_PARENT_SEL
+ ', .dataTables_wrapper ' + DROPDOWN_PARENT_SEL;
$(document).on('shown.bs.dropdown', SCOPE_SEL, function(e) {
var $container = $(this);
var $trigger = $container.find('[data-toggle="dropdown"]');
var $menu = $container.find('.dropdown-menu');
if (!$menu.length || !$trigger.length) return;
// Mark the menu so we can style it and find it later
$menu.addClass('ots-row-dropdown');
// Store original parent so we can put it back on close
$menu.data('ots-original-parent', $container);
// Get trigger position in viewport
var btnRect = $trigger[0].getBoundingClientRect();
// Move to body
$menu.detach().appendTo('body');
// Position below the trigger button, aligned to the right edge
var top = btnRect.bottom + 2;
var left = btnRect.right - $menu.outerWidth();
// Keep within viewport bounds
if (left < 8) left = 8;
if (top + $menu.outerHeight() > window.innerHeight - 8) {
// Open upward if no room below
top = btnRect.top - $menu.outerHeight() - 2;
}
if (top < 8) top = 8;
$menu.css({
position: 'fixed',
top: top + 'px',
left: left + 'px',
display: 'block'
});
});
// When the dropdown closes, move the menu back to its original parent
$(document).on('hide.bs.dropdown', SCOPE_SEL, function(e) {
var $container = $(this);
var $menu = $('body > .dropdown-menu.ots-row-dropdown').filter(function() {
var orig = $(this).data('ots-original-parent');
return orig && orig[0] === $container[0];
});
if ($menu.length) {
$menu.removeClass('ots-row-dropdown').css({ position: '', top: '', left: '', display: '' });
$menu.detach().appendTo($container);
}
});
// Also close any orphaned body-appended dropdown on outside click
$(document).on('click', function(e) {
var $openMenus = $('body > .dropdown-menu.ots-row-dropdown');
if (!$openMenus.length) return;
// If the click is inside the menu itself, let it through
if ($(e.target).closest('.ots-row-dropdown').length) return;
$openMenus.each(function() {
var $menu = $(this);
var $parent = $menu.data('ots-original-parent');
if ($parent && $parent.length) {
$menu.removeClass('ots-row-dropdown').css({ position: '', top: '', left: '', display: '' });
$menu.detach().appendTo($parent);
$parent.removeClass('open show');
}
});
});
}
/**
* Initialize all features when DOM is ready
*/
function init() {
initSidebarToggle();
initSidebarSectionToggles();
initThemeToggle();
initDropdowns();
initRowDropdowns();
initSearch();
initPageInteractions();
initDataTables();
enhanceTables();
makeResponsive();
initChartSafeguard();
updateSidebarWidth();
updateSidebarNavOffset();
// updateSidebarGap() disabled - use CSS variables instead
initUserProfileQrFix();
var debouncedUpdate = debounce(function() {
updateSidebarNavOffset();
updateSidebarWidth();
// updateSidebarGap() disabled - use CSS variables instead
}, 120);
window.addEventListener('resize', debouncedUpdate);
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
// Replace broken QR images in user profile modals with a friendly placeholder
function initUserProfileQrFix() {
function replaceIfEmptyDataUri(el) {
try {
if (!el || el.tagName !== 'IMG') return false;
if (!el.closest || !el.closest('.modal, .modal-dialog')) return false;
var src = el.getAttribute('src') || '';
// matches empty/invalid data uri like "data:image/png;base64," or very short payloads
if (/^data:image\/[a-zA-Z0-9.+-]+;base64,\s*$/.test(src) || (src.indexOf('data:image') === 0 && src.split(',')[1] && src.split(',')[1].length < 10)) {
console.warn('[OTS] Replacing empty data URI for QR image inside modal:', src);
var svg = 'data:image/svg+xml;utf8,' + encodeURIComponent(
'<svg xmlns="http://www.w3.org/2000/svg" width="240" height="160">'
+ '<rect width="100%" height="100%" fill="#213041"/>'
+ '<text x="50%" y="50%" fill="#9fb1c8" font-family="Arial,Helvetica,sans-serif" font-size="14" text-anchor="middle" dy=".3em">QR unavailable</text>'
+ '</svg>'
);
if (el.getAttribute('data-ots-replaced') === '1') return true;
el.setAttribute('data-ots-replaced', '1');
el.src = svg;
el.alt = 'QR code unavailable';
var parent = el.parentNode;
if (parent && !parent.querySelector('.ots-qr-note')) {
var p = document.createElement('p');
p.className = 'ots-qr-note text-muted';
p.style.marginTop = '6px';
p.textContent = 'QR failed to load. Close and re-open the Edit Profile dialog to retry.';
parent.appendChild(p);
}
return true;
}
} catch (err) {
console.error('[OTS] replaceIfEmptyDataUri error', err);
}
return false;
}
// Initial quick scan for any modal images already present
try {
var imgs = document.querySelectorAll('.modal img, .modal-dialog img');
imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
} catch (e) {}
// Observe DOM for modals being added (some UIs load modal content via AJAX)
try {
var mo = new MutationObserver(function(muts) {
muts.forEach(function(m) {
m.addedNodes && m.addedNodes.forEach(function(node) {
try {
if (!node) return;
if (node.nodeType === 1) {
if (node.matches && node.matches('.modal, .modal-dialog')) {
var imgs = node.querySelectorAll('img');
imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
} else {
var imgs = node.querySelectorAll && node.querySelectorAll('img');
imgs && imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
}
}
} catch (err) {}
});
});
});
mo.observe(document.body, { childList: true, subtree: true });
// stop observing after 20s to avoid long-lived observers in older pages
setTimeout(function() { try { mo.disconnect(); } catch (e) {} }, 20000);
} catch (err) {}
// Also a short polling fallback for dynamic UIs for the first 6s
var checks = 0;
var interval = setInterval(function() {
try {
var imgs = document.querySelectorAll('.modal img, .modal-dialog img');
imgs.forEach(function(i) { replaceIfEmptyDataUri(i); });
} catch (e) {}
checks += 1;
if (checks > 12) clearInterval(interval);
}, 500);
}