Files
OTSSignsTheme/ots-signs/views/theme-scripts.twig
2026-02-12 10:24:55 -05:00

1555 lines
57 KiB
Twig
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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 <li> 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 <body> 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 <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';
}
}
}
/**
* 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 <body> 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 <body>)
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 <body>
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 <a> 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 = '<button type="button" class="ots-upload-close" data-dismiss="modal" aria-label="Close">' +
'<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M15 5L5 15M5 5l10 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>' +
'</button>';
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(
'<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);
}