/** * 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() { const sidebar = document.querySelector('.ots-sidebar'); if (!sidebar) return; const w = sidebar.offsetWidth; document.documentElement.style.setProperty('--ots-sidebar-actual-width', w + 'px'); } /** * 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() { /* No-op: sidebar uses flex-direction:column so the header and nav content are separate flex children that never overlap. Previously this set padding-top:~72px which created a huge gap. */ var nav = document.querySelector('.ots-sidebar .sidebar-nav, .ots-sidebar .ots-sidebar-nav'); if (nav) { try { nav.style.removeProperty('padding-top'); } catch(e) { nav.style.paddingTop = ''; } } } /** * Detect whether the playlist/layout editor modal is open and toggle * body.ots-playlist-editor-active accordingly. Because the editor is * loaded via AJAX into #editor-container, a one-shot check at page-load * is not enough – we use a MutationObserver that watches for DOM changes. */ function updatePlaylistEditorBackground() { var isActive = !!document.querySelector('.editor-modal, #playlist-editor, #layout-editor'); document.body.classList.toggle('ots-playlist-editor-active', isActive); } /* Start a MutationObserver that fires updatePlaylistEditorBackground whenever children are added to or removed from the page. */ (function initEditorObserver() { // Run once immediately updatePlaylistEditorBackground(); var target = document.body; if (!target) return; var editorObs = new MutationObserver(function() { updatePlaylistEditorBackground(); }); editorObs.observe(target, { childList: true, subtree: true }); })(); /** * 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'); syncSubmenuDisplayForState(nowCollapsed); updateSidebarNavOffset(); updateSidebarStateClass(); // Update measured width immediately and again after CSS transition updateSidebarWidth(); setTimeout(updateSidebarWidth, 250); }); } 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'); syncSubmenuDisplayForState(false); updateSidebarNavOffset(); updateSidebarStateClass(); // Update measured width immediately and again after CSS transition updateSidebarWidth(); setTimeout(updateSidebarWidth, 250); }); } // 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(); } /** * Build flyout headers for each sidebar-submenu. * Pulls the icon class(es) and label from the parent group toggle * and injects a styled header
  • at the top of the submenu. * Idempotent — skips submenus that already have a header. */ function buildFlyoutHeaders() { var groups = document.querySelectorAll('.sidebar-group'); groups.forEach(function(group) { var submenu = group.querySelector('.sidebar-submenu'); if (!submenu) return; if (submenu.querySelector('.flyout-header')) return; var toggle = group.querySelector('.sidebar-group-toggle'); if (!toggle) return; var iconEl = toggle.querySelector('.ots-nav-icon'); var textEl = toggle.querySelector('.ots-nav-text'); if (!textEl) return; var label = textEl.textContent.trim(); var header = document.createElement('li'); header.className = 'flyout-header'; header.setAttribute('aria-hidden', 'true'); if (iconEl) { var icon = document.createElement('span'); icon.className = iconEl.className; icon.classList.add('flyout-header-icon'); icon.setAttribute('aria-hidden', 'true'); header.appendChild(icon); } var text = document.createElement('span'); text.className = 'flyout-header-text'; text.textContent = label; header.appendChild(text); submenu.insertBefore(header, submenu.firstChild); }); } /** * When toggling between collapsed/expanded, sync all submenu inline * display styles so that: * - Collapsed: no inline display → CSS :hover handles flyouts * - Expanded: inline display block/none based on is-open state */ function syncSubmenuDisplayForState(isCollapsed) { var groups = document.querySelectorAll('.sidebar-group'); groups.forEach(function(group) { var submenu = group.querySelector('.sidebar-submenu'); if (!submenu) return; if (isCollapsed) { submenu.style.removeProperty('display'); } else { var isOpen = group.classList.contains('is-open'); submenu.style.display = isOpen ? 'block' : 'none'; } }); } /** * 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; if (submenu) { const isOpen = group.classList.contains('is-open'); const sidebarEl = document.querySelector('.ots-sidebar'); const isCollapsed = sidebarEl && sidebarEl.classList.contains('collapsed'); if (!isCollapsed) { submenu.style.display = isOpen ? 'block' : 'none'; } else { submenu.style.removeProperty('display'); } 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 sidebarEl = document.querySelector('.ots-sidebar'); const isCollapsed = sidebarEl && sidebarEl.classList.contains('collapsed'); if (isCollapsed) 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'; requestAnimationFrame(updateSidebarWidth); }); }); } /** * Initialize dropdown menus */ function initDropdowns() { // Only handle the user-menu dropdown. // Let Bootstrap handle topbar nav dropdowns (Schedule, Design, etc.) natively // so that links like Dayparting navigate normally. var userToggle = document.querySelector('#navbarUserMenu'); if (!userToggle) return; var userDropdown = userToggle.closest('.dropdown'); if (!userDropdown) return; var userMenu = userDropdown.querySelector('.dropdown-menu'); if (!userMenu) return; // ── Neutralize Bootstrap ────────────────────────────────────────── // Remove data-toggle so Bootstrap's delegated handler never fires. userToggle.removeAttribute('data-toggle'); try { var jq = window.jQuery || window.$; if (jq) { jq(userToggle).off('.bs.dropdown'); jq(userDropdown).off('.bs.dropdown'); } } catch (e) {} // ── Move menu to ONCE and leave it there ─────────────────── // This escapes any overflow:hidden ancestors permanently. // We toggle visibility via the .ots-user-menu-open class (no DOM moves). document.body.appendChild(userMenu); // Mark it so the MutationObserver in observeAndFloatMenus() skips it userMenu.setAttribute('data-ots-floating', 'permanent'); userMenu.setAttribute('data-ots-floating-obs', '1'); // Start hidden userMenu.classList.add('ots-user-menu-body'); userMenu.classList.remove('show', 'ots-floating-menu'); function positionMenu() { var rect = userToggle.getBoundingClientRect(); var menuWidth = userMenu.offsetWidth || 220; var spaceRight = window.innerWidth - rect.right; // Vertically: below the avatar userMenu.style.top = Math.round(rect.bottom + 6) + 'px'; // Horizontally: align right edge to avatar right edge, // but fall back to left-aligned if not enough space if (spaceRight >= menuWidth) { userMenu.style.left = Math.round(rect.left) + 'px'; userMenu.style.right = 'auto'; } else { userMenu.style.left = 'auto'; userMenu.style.right = Math.round(window.innerWidth - rect.right) + 'px'; } } function openUserMenu() { userDropdown.classList.remove('show'); userMenu.classList.remove('show'); positionMenu(); userMenu.classList.add('ots-user-menu-open'); userDropdown.classList.add('active'); } function closeUserMenu() { userMenu.classList.remove('ots-user-menu-open'); userDropdown.classList.remove('active', 'show'); userMenu.classList.remove('show'); } // ── Click handler on the toggle element itself ───────────────────── userToggle.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); if (userMenu.classList.contains('ots-user-menu-open')) { closeUserMenu(); } else { openUserMenu(); } }); // ── Close when clicking outside ─────────────────────────────────── document.addEventListener('click', function(e) { if (!userMenu.classList.contains('ots-user-menu-open')) return; if (userToggle.contains(e.target)) return; if (userMenu.contains(e.target)) return; closeUserMenu(); }); // ── Close when a modal opens ───────────────────────────────────── // Menu items like Preferences / Edit Profile trigger Bootstrap modals // via .XiboFormButton — close the dropdown as soon as any modal shows. document.addEventListener('show.bs.modal', function() { closeUserMenu(); }, true); try { var jq = window.jQuery || window.$; if (jq) { jq(document).on('show.bs.modal', function() { closeUserMenu(); }); } } catch (e) {} // ── Reposition on scroll/resize ─────────────────────────────────── window.addEventListener('scroll', function() { if (userMenu.classList.contains('ots-user-menu-open')) positionMenu(); }, true); window.addEventListener('resize', function() { if (userMenu.classList.contains('ots-user-menu-open')) positionMenu(); }); } /** * 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) return; // Skip if already floating or permanently managed by initDropdowns var floatAttr = menuEl.getAttribute('data-ots-floating'); if (floatAttr === '1' || floatAttr === 'permanent') 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) return; var floatAttr = menuEl.getAttribute('data-ots-floating'); // Skip permanently managed menus and menus that aren't floating if (floatAttr === 'permanent' || floatAttr !== '1') return; try { menuEl.removeAttribute('data-ots-floating'); menuEl.classList.remove('ots-floating-menu'); // Clear ALL inline styles that floatMenu() may have set (including // transform which was previously missed, causing stale styles). menuEl.style.position = ''; menuEl.style.top = ''; menuEl.style.left = ''; menuEl.style.zIndex = ''; menuEl.style.minWidth = ''; menuEl.style.pointerEvents = ''; menuEl.style.transform = ''; menuEl.style.visibility = ''; menuEl.style.display = ''; menuEl.style.opacity = ''; // 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 and ) if (effectiveTheme === 'light') { body.classList.add('ots-light-mode'); root.classList.add('ots-light-mode'); updateThemeLabel(); } // Toggle on click (keep 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'; } } } /** * DataTable row action dropdowns — fully managed by OTS theme. * * Bootstrap 4 + Popper.js positions menus with transform: translate3d(), * but the theme CSS sets transform: none !important which breaks that. * Detaching the menu to also triggers Bootstrap's hide event. * * Solution: intercept the click on the toggle button in the capture phase * (before Bootstrap sees it), prevent Bootstrap from handling it, and * manage show/hide/position entirely ourselves. */ // Module-level reference to row dropdown's closeMenu, set by initRowDropdowns(). var _closeRowDropdown = null; function initRowDropdowns() { var TOGGLE_SEL = '.dropdown-menu-container [data-toggle="dropdown"], .btn-group [data-toggle="dropdown"]'; var activeMenu = null; // currently open menu element (in ) var activeParent = null; // original parent (.btn-group / .dropdown-menu-container) var activeTrigger = null; function openMenu(trigger) { closeMenu(); // close any previously open menu first var $trigger = $(trigger); var $parent = $trigger.closest('.dropdown-menu-container, .btn-group'); var $menu = $parent.find('.dropdown-menu').first(); if (!$menu.length) return; activeTrigger = trigger; activeParent = $parent[0]; // Snapshot button position while menu is still in the normal DOM var btnRect = trigger.getBoundingClientRect(); // Detach menu and append to body so it escapes overflow:hidden $menu.detach().appendTo('body'); activeMenu = $menu[0]; // Make visible-but-hidden so we can measure it activeMenu.style.cssText = 'display:block !important; visibility:hidden !important; position:fixed !important; transform:none !important;'; var menuW = $menu.outerWidth() || 180; var menuH = $menu.outerHeight() || 200; // Compute position: below button, right-aligned to button's right edge var top = btnRect.bottom + 2; var left = btnRect.right - menuW; // Viewport bounds check if (left < 8) left = 8; if (left + menuW > window.innerWidth - 8) left = window.innerWidth - menuW - 8; if (top + menuH > window.innerHeight - 8) { top = btnRect.top - menuH - 2; // flip above the button } if (top < 8) top = 8; // Apply final position — every property with !important activeMenu.style.cssText = [ 'position:fixed !important', 'top:' + top + 'px !important', 'left:' + left + 'px !important', 'right:auto !important', 'bottom:auto !important', 'display:block !important', 'visibility:visible !important', 'transform:none !important', 'will-change:auto !important', 'margin:0 !important', 'z-index:2147483647 !important' ].join(';') + ';'; $menu.addClass('ots-row-dropdown show'); $parent.addClass('show'); } function closeMenu() { if (!activeMenu) return; var $menu = $(activeMenu); var $parent = $(activeParent); // Clear all inline styles activeMenu.style.cssText = ''; $menu.removeClass('ots-row-dropdown show'); // Move menu back to its original parent $menu.detach().appendTo($parent); $parent.removeClass('show open'); activeMenu = null; activeParent = null; activeTrigger = null; } // Expose closeMenu so closeAllDropdowns() can reach it _closeRowDropdown = closeMenu; // Intercept clicks in CAPTURE phase — runs BEFORE Bootstrap's handler. document.addEventListener('click', function(e) { var toggle = e.target.closest(TOGGLE_SEL); if (!toggle) { // Click was not on a toggle — close any open menu // (unless click is inside the open menu itself) if (activeMenu && !e.target.closest('.ots-row-dropdown')) { closeMenu(); } return; } // Only handle toggles inside DataTable areas var inTable = toggle.closest('.XiboData') || toggle.closest('.XiboGrid') || toggle.closest('#datatable-container') || toggle.closest('.dataTables_wrapper'); if (!inTable) return; // not a row dropdown — let Bootstrap handle it // Prevent Bootstrap from handling this dropdown e.preventDefault(); e.stopImmediatePropagation(); // Toggle behaviour: if same trigger, close; otherwise open if (activeTrigger === toggle && activeMenu) { closeMenu(); } else { openMenu(toggle); } }, true); // ← true = capture phase // Close on Escape key document.addEventListener('keydown', function(e) { if (e.key === 'Escape' && activeMenu) { closeMenu(); } }); // Close on any scroll (window or scrollable ancestor) window.addEventListener('scroll', function() { if (activeMenu) closeMenu(); }, true); // Block Bootstrap's show/hide events for DataTable row dropdowns // so it doesn't interfere with our manual management. $(document).on('show.bs.dropdown hide.bs.dropdown', function(e) { var toggle = e.relatedTarget; if (!toggle) return; var inTable = toggle.closest('.XiboData') || toggle.closest('.XiboGrid') || toggle.closest('#datatable-container') || toggle.closest('.dataTables_wrapper'); if (inTable) { e.preventDefault(); } }); } /** * Close every open dropdown / popover on the page. * Covers: Bootstrap 4 native dropdowns, OTS custom dropdowns, * the user-menu, notification drawer, and DataTable row menus. */ function closeAllDropdowns() { try { // 0. Close row dropdown managed by initRowDropdowns() if (typeof _closeRowDropdown === 'function') _closeRowDropdown(); // Also force-remove any stray ots-row-dropdown elements left on document.querySelectorAll('.ots-row-dropdown').forEach(function(m) { m.classList.remove('show', 'ots-row-dropdown'); m.style.cssText = ''; }); // 1. Bootstrap 4 native dropdowns (.show on the wrapper or the menu) document.querySelectorAll('.dropdown.show, .btn-group.show').forEach(function(el) { el.classList.remove('show'); var m = el.querySelector('.dropdown-menu.show'); if (m) m.classList.remove('show'); }); document.querySelectorAll('.dropdown-menu.show').forEach(function(m) { m.classList.remove('show'); }); // 2. OTS custom dropdowns that use .active document.querySelectorAll('.dropdown.active, .ots-topbar-action .dropdown.active, .ots-page-actions .dropdown.active').forEach(function(el) { el.classList.remove('active'); }); // 3. User menu (body-level floating menu) var userMenu = document.querySelector('.ots-user-menu-body.ots-user-menu-open'); if (userMenu) { userMenu.classList.remove('ots-user-menu-open'); var userDropdown = document.querySelector('#navbarUserMenu'); if (userDropdown) { var dd = userDropdown.closest('.dropdown'); if (dd) dd.classList.remove('active', 'show'); } } // 4. DataTable button collections document.querySelectorAll('.dt-buttons.active').forEach(function(w) { w.classList.remove('active'); }); document.querySelectorAll('.dt-button-collection.show').forEach(function(c) { c.classList.remove('show'); c.style.display = 'none'; }); // 5. jQuery-level Bootstrap cleanup (if available) var jq = window.jQuery || window.$; if (jq) { jq('.dropdown-toggle[aria-expanded="true"]').attr('aria-expanded', 'false'); jq('.dropdown-menu.show').removeClass('show'); jq('.dropdown.show, .btn-group.show').removeClass('show'); } } catch (err) { // never let this break the page } } /** * Wire up global listeners that trigger closeAllDropdowns(). * Called once from init(). */ function initGlobalDropdownDismiss() { // ── Close when a Bootstrap modal / dialog opens ───────────────── document.addEventListener('show.bs.modal', closeAllDropdowns, true); try { var jq = window.jQuery || window.$; if (jq) { jq(document).on('show.bs.modal', closeAllDropdowns); // Xibo opens modals when .XiboFormButton / .XiboAjaxSubmit are clicked jq(document).on('click', '.XiboFormButton, .XiboAjaxSubmit, .XiboFormRender', function() { closeAllDropdowns(); }); } } catch (e) {} // ── Close when any inside a dropdown is clicked (page nav) ── document.addEventListener('click', function(e) { var link = e.target.closest('.dropdown-menu a, .ots-topbar a.nav-link, .ots-topbar a.dropdown-item, .XiboFormButton'); if (link && !e.defaultPrevented) { closeAllDropdowns(); } }); // ── Close on History navigation (Xibo uses pushState for AJAX pages) ── window.addEventListener('popstate', closeAllDropdowns); // Intercept pushState / replaceState so we catch Xibo's AJAX navigation try { var origPush = history.pushState; var origReplace = history.replaceState; history.pushState = function() { origPush.apply(this, arguments); closeAllDropdowns(); }; history.replaceState = function() { origReplace.apply(this, arguments); closeAllDropdowns(); }; } catch (e) {} // ── Close when main content area changes (AJAX page swap) ─────── try { var content = document.getElementById('content') || document.querySelector('.ots-content'); if (content) { var contentObs = new MutationObserver(debounce(closeAllDropdowns, 80)); contentObs.observe(content, { childList: true }); } } catch (e) {} } /** * Initialize all features when DOM is ready */ function init() { initSidebarToggle(); initSidebarSectionToggles(); buildFlyoutHeaders(); initThemeToggle(); initDropdowns(); initRowDropdowns(); initGlobalDropdownDismiss(); initSearch(); initPageInteractions(); initDataTables(); enhanceTables(); makeResponsive(); initChartSafeguard(); updateSidebarWidth(); updateSidebarNavOffset(); updatePlaylistEditorBackground(); // updateSidebarGap() disabled - use CSS variables instead initUserProfileQrFix(); var debouncedUpdate = debounce(function() { updateSidebarNavOffset(); updateSidebarWidth(); updatePlaylistEditorBackground(); // 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(); } })(); /** * OTS: Enhance all Xibo form modals to match the upload modal design. * Runs on every shown.bs.modal event and also exposed as window.otsEnhanceModal() * for direct invocation from form callbacks like mediaEditFormOpen. */ (function() { 'use strict'; var OTS_CLOSE_SVG = ''; function enhanceModal(modal) { var $m = window.jQuery ? window.jQuery(modal) : null; if (!$m || !$m.length) return; // Don't re-enhance if ($m.data('ots-enhanced')) return; $m.data('ots-enhanced', true); // Skip the custom upload modal (it has its own styling) if ($m.hasClass('ots-upload-modal') || $m.attr('id') === 'ots-upload-modal') return; // Add the OTS edit modal class $m.addClass('ots-edit-media-modal'); // Replace the default close button with SVG version var $closeBtn = $m.find('.modal-header .close, .modal-header button[data-dismiss="modal"]:not(.ots-upload-close)'); if ($closeBtn.length) { $closeBtn.first().replaceWith(OTS_CLOSE_SVG); } } // Expose globally so page callbacks can invoke it directly window.otsEnhanceModal = enhanceModal; // Hook into every modal show event if (window.jQuery) { window.jQuery(document).on('shown.bs.modal', '.modal', function() { enhanceModal(this); }); } else { document.addEventListener('shown.bs.modal', function(e) { var modal = e.target; if (modal && modal.classList && modal.classList.contains('modal')) { enhanceModal(modal); } }, true); } })(); // 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( '' + '' + 'QR unavailable' + '' ); 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); }