- Updated button classes for consistency in the playersoftware-page, playlist-page, resolution-page, schedule-page, settings-page, syncgroup-page, tag-page, task-page, template-page, transition-page, user-page, and usergroup-page. - Removed unnecessary text from button titles and ensured all buttons have the 'ots-toolbar-btn' class for uniformity. - Cleaned up the code by removing commented-out sections and ensuring proper indentation.
1316 lines
48 KiB
Twig
1316 lines
48 KiB
Twig
/**
|
||
* OTS Signage Modern Theme - Client-Side Utilities
|
||
* Sidebar toggle, dropdown menus, and UI interactions
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// Apply saved or system-preferred theme as early as possible to avoid
|
||
// a flash from dark -> light when navigating between pages.
|
||
(function() {
|
||
try {
|
||
var stored = localStorage.getItem('ots-theme-mode');
|
||
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||
var initial = stored || (prefersLight ? 'light' : 'light');
|
||
if (initial === 'light') {
|
||
document.documentElement.classList.add('ots-light-mode');
|
||
if (document.body) document.body.classList.add('ots-light-mode');
|
||
} else {
|
||
document.documentElement.classList.remove('ots-light-mode');
|
||
if (document.body) document.body.classList.remove('ots-light-mode');
|
||
}
|
||
} catch (err) {
|
||
// ignore failures (e.g. localStorage unavailable)
|
||
}
|
||
})();
|
||
|
||
const STORAGE_KEYS = {
|
||
sidebarCollapsed: 'otsTheme:sidebarCollapsed'
|
||
};
|
||
|
||
/**
|
||
* Sidebar width is now handled purely by CSS classes (.ots-sidebar-collapsed).
|
||
* This function is kept as a no-op for backward compatibility.
|
||
*/
|
||
function updateSidebarWidth() {
|
||
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.
|
||
const userDropdown = document.querySelector('#navbarUserMenu') && document.querySelector('#navbarUserMenu').closest('.dropdown');
|
||
if (!userDropdown) return;
|
||
|
||
const userMenu = userDropdown.querySelector('.dropdown-menu');
|
||
if (!userMenu) return;
|
||
|
||
userDropdown.addEventListener('click', function(e) {
|
||
if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) {
|
||
e.preventDefault();
|
||
const nowActive = !userDropdown.classList.contains('active');
|
||
userDropdown.classList.toggle('active');
|
||
|
||
// Float / unfloat the user menu
|
||
try {
|
||
if (nowActive) {
|
||
floatMenu(userMenu, userDropdown);
|
||
} else {
|
||
unfloatMenu(userMenu);
|
||
}
|
||
} catch (err) { /* ignore */ }
|
||
|
||
// Compute placement to avoid going off-screen
|
||
const trigger = userDropdown.querySelector('#navbarUserMenu');
|
||
if (trigger) {
|
||
userMenu.classList.remove('dropdown-menu-left', 'dropdown-menu-right');
|
||
const trigRect = trigger.getBoundingClientRect();
|
||
const menuWidth = userMenu.offsetWidth || 220;
|
||
const spaceRight = window.innerWidth - trigRect.right;
|
||
const spaceLeft = trigRect.left;
|
||
if (spaceRight < menuWidth && spaceLeft > menuWidth) {
|
||
userMenu.classList.add('dropdown-menu-left');
|
||
} else {
|
||
userMenu.classList.add('dropdown-menu-right');
|
||
}
|
||
}
|
||
}
|
||
});
|
||
|
||
// Close user menu when clicking outside
|
||
document.addEventListener('click', function(e) {
|
||
if (!userDropdown.contains(e.target) && !userMenu.contains(e.target)) {
|
||
const hadActive = userDropdown.classList.contains('active');
|
||
userDropdown.classList.remove('active');
|
||
if (hadActive) {
|
||
try { unfloatMenu(userMenu); } catch (err) {}
|
||
}
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Float a menu element into document.body so it can escape overflowed parents.
|
||
* Adds `.ots-floating-menu` and positions absolutely based on the trigger rect.
|
||
*/
|
||
function floatMenu(menuEl, triggerEl) {
|
||
if (!menuEl || !triggerEl || menuEl.getAttribute('data-ots-floating') === '1') return;
|
||
try {
|
||
// Remember original parent and next sibling so we can restore later
|
||
menuEl._otsOriginalParent = menuEl.parentNode || null;
|
||
menuEl._otsOriginalNext = menuEl.nextSibling || null;
|
||
menuEl.setAttribute('data-ots-floating', '1');
|
||
menuEl.classList.add('ots-floating-menu');
|
||
// Append to body
|
||
document.body.appendChild(menuEl);
|
||
|
||
const rect = triggerEl.getBoundingClientRect();
|
||
// Default placement below trigger, align to left edge
|
||
const top = Math.max(8, Math.round(rect.bottom + window.scrollY + 6));
|
||
const left = Math.max(8, Math.round(rect.left + window.scrollX));
|
||
|
||
// Use fixed positioning so the menu floats above all stacking contexts
|
||
// Use fixed positioning so the menu floats above all stacking contexts
|
||
try {
|
||
menuEl.style.setProperty('position', 'fixed', 'important');
|
||
menuEl.style.setProperty('top', Math.max(6, Math.round(rect.bottom + 6)) + 'px', 'important');
|
||
menuEl.style.setProperty('left', left + 'px', 'important');
|
||
// Use the maximum reasonable z-index to ensure it appears on top
|
||
menuEl.style.setProperty('z-index', '2147483647', 'important');
|
||
// Ensure transforms won't clip rendering
|
||
menuEl.style.setProperty('transform', 'none', 'important');
|
||
menuEl.style.setProperty('min-width', (rect.width) + 'px', 'important');
|
||
menuEl.style.setProperty('pointer-events', 'auto', 'important');
|
||
} catch (err) {
|
||
// fallback to non-important inline style
|
||
menuEl.style.position = 'fixed';
|
||
menuEl.style.top = Math.max(6, Math.round(rect.bottom + 6)) + 'px';
|
||
menuEl.style.left = left + 'px';
|
||
menuEl.style.zIndex = '2147483647';
|
||
menuEl.style.transform = 'none';
|
||
menuEl.style.minWidth = (rect.width) + 'px';
|
||
menuEl.style.pointerEvents = 'auto';
|
||
}
|
||
|
||
// Reposition on scroll/resize while open
|
||
const reposition = function() {
|
||
if (menuEl.getAttribute('data-ots-floating') !== '1') return;
|
||
const r = triggerEl.getBoundingClientRect();
|
||
// For fixed positioning we only need viewport coords
|
||
menuEl.style.top = Math.max(6, Math.round(r.bottom + 6)) + 'px';
|
||
menuEl.style.left = Math.max(6, Math.round(r.left)) + 'px';
|
||
};
|
||
menuEl._otsReposition = reposition;
|
||
window.addEventListener('scroll', reposition, true);
|
||
window.addEventListener('resize', reposition);
|
||
|
||
// Guard: some libraries move/drop menus. Keep a short-lived guard that
|
||
// re-attaches the menu to body and re-applies important styles while open.
|
||
let guardCount = 0;
|
||
const guard = setInterval(function() {
|
||
try {
|
||
if (menuEl.getAttribute('data-ots-floating') !== '1') {
|
||
clearInterval(guard);
|
||
return;
|
||
}
|
||
// If parent moved, re-append to body
|
||
if (menuEl.parentNode !== document.body) document.body.appendChild(menuEl);
|
||
// Re-ensure important styles
|
||
menuEl.style.setProperty('z-index', '2147483647', 'important');
|
||
menuEl.style.setProperty('position', 'fixed', 'important');
|
||
} catch (err) {}
|
||
guardCount += 1;
|
||
if (guardCount > 120) {
|
||
clearInterval(guard);
|
||
}
|
||
}, 100);
|
||
menuEl._otsGuard = guard;
|
||
} catch (err) {
|
||
console.warn('[OTS] floatMenu failed', err);
|
||
}
|
||
}
|
||
|
||
function unfloatMenu(menuEl) {
|
||
if (!menuEl || menuEl.getAttribute('data-ots-floating') !== '1') return;
|
||
try {
|
||
menuEl.removeAttribute('data-ots-floating');
|
||
menuEl.classList.remove('ots-floating-menu');
|
||
menuEl.style.position = '';
|
||
menuEl.style.top = '';
|
||
menuEl.style.left = '';
|
||
menuEl.style.zIndex = '';
|
||
menuEl.style.minWidth = '';
|
||
menuEl.style.pointerEvents = '';
|
||
// Remove reposition listeners
|
||
if (menuEl._otsReposition) {
|
||
window.removeEventListener('scroll', menuEl._otsReposition, true);
|
||
window.removeEventListener('resize', menuEl._otsReposition);
|
||
delete menuEl._otsReposition;
|
||
}
|
||
// Attempt to restore the original parent and insertion point
|
||
try {
|
||
if (menuEl._otsOriginalParent) {
|
||
if (menuEl._otsOriginalNext && menuEl._otsOriginalNext.parentNode === menuEl._otsOriginalParent) {
|
||
menuEl._otsOriginalParent.insertBefore(menuEl, menuEl._otsOriginalNext);
|
||
} else {
|
||
menuEl._otsOriginalParent.appendChild(menuEl);
|
||
}
|
||
delete menuEl._otsOriginalParent;
|
||
delete menuEl._otsOriginalNext;
|
||
} else {
|
||
// fallback: append to body (leave it there)
|
||
document.body.appendChild(menuEl);
|
||
}
|
||
} catch (err) {
|
||
document.body.appendChild(menuEl);
|
||
}
|
||
} catch (err) {
|
||
console.warn('[OTS] unfloatMenu failed', err);
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Observe document for dynamically added dropdown menus and float them when necessary.
|
||
*/
|
||
function observeAndFloatMenus() {
|
||
try {
|
||
const mo = new MutationObserver(function(muts) {
|
||
muts.forEach(function(m) {
|
||
(m.addedNodes || []).forEach(function(node) {
|
||
try {
|
||
if (!node || node.nodeType !== 1) return;
|
||
// If the node itself is a dropdown menu
|
||
if (node.classList && node.classList.contains('dropdown-menu')) {
|
||
attachIfNeeded(node);
|
||
}
|
||
// Or contains dropdown menus
|
||
const menus = node.querySelectorAll && node.querySelectorAll('.dropdown-menu');
|
||
if (menus && menus.length) {
|
||
menus.forEach(attachIfNeeded);
|
||
}
|
||
} catch (err) {}
|
||
});
|
||
});
|
||
});
|
||
mo.observe(document.body, { childList: true, subtree: true });
|
||
// keep alive for the lifetime of the page
|
||
} catch (err) {
|
||
// ignore
|
||
}
|
||
|
||
function attachIfNeeded(menu) {
|
||
try {
|
||
if (!menu || menu.getAttribute('data-ots-floating-obs') === '1') return;
|
||
menu.setAttribute('data-ots-floating-obs', '1');
|
||
// find a reasonable trigger element: aria-labelledby or previous element
|
||
let trigger = null;
|
||
const labelled = menu.getAttribute('aria-labelledby');
|
||
if (labelled) trigger = document.getElementById(labelled);
|
||
if (!trigger) trigger = menu._otsOriginalParent ? menu._otsOriginalParent.querySelector('[data-toggle="dropdown"]') : null;
|
||
if (!trigger) trigger = menu.previousElementSibling || null;
|
||
// If the menu is visible and inside an overflowed ancestor, float it
|
||
const rect = menu.getBoundingClientRect();
|
||
if (rect.width === 0 && rect.height === 0) return; // not rendered yet
|
||
if (isClippedByOverflow(menu) && trigger) {
|
||
floatMenu(menu, trigger);
|
||
}
|
||
// Also watch for when dropdown gets toggled active via class
|
||
const obs = new MutationObserver(function(ms) {
|
||
ms.forEach(function(mm) {
|
||
if (mm.type === 'attributes' && mm.attributeName === 'class') {
|
||
const isActive = menu.classList.contains('show') || menu.parentNode && menu.parentNode.classList.contains('active');
|
||
if (isActive && trigger) floatMenu(menu, trigger);
|
||
if (!isActive) unfloatMenu(menu);
|
||
}
|
||
});
|
||
});
|
||
obs.observe(menu, { attributes: true, attributeFilter: ['class'] });
|
||
} catch (err) {}
|
||
}
|
||
|
||
function isClippedByOverflow(el) {
|
||
let p = el.parentElement;
|
||
while (p && p !== document.body) {
|
||
const s = window.getComputedStyle(p);
|
||
if (/(hidden|auto|scroll)/.test(s.overflow + s.overflowY + s.overflowX)) {
|
||
const r = el.getBoundingClientRect();
|
||
const pr = p.getBoundingClientRect();
|
||
// if element overflows parent's rect then it's clipped
|
||
if (r.bottom > pr.bottom || r.top < pr.top || r.left < pr.left || r.right > pr.right) return true;
|
||
}
|
||
p = p.parentElement;
|
||
}
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Force common menu classes to the top by moving them to body and keeping them there.
|
||
* This is the most aggressive approach to ensure menus are never clipped.
|
||
*/
|
||
function forceTopMenus() {
|
||
const selectors = ['.dropdown-menu', '.ots-notif-menu', '.ots-user-menu', '.context-menu', '.row-menu', '.rowMenu', '.menu-popover'];
|
||
|
||
function moveToBody(el) {
|
||
try {
|
||
if (!el || el.getAttribute('data-ots-moved-to-body') === '1') return;
|
||
|
||
// Store original parent info
|
||
el._otsOriginalParent = el.parentElement;
|
||
el._otsOriginalNextSibling = el.nextElementSibling;
|
||
|
||
el.setAttribute('data-ots-moved-to-body', '1');
|
||
|
||
// Force fixed positioning with maximum z-index
|
||
el.style.setProperty('position', 'fixed', 'important');
|
||
el.style.setProperty('z-index', '2147483647', 'important');
|
||
el.style.setProperty('transform', 'none', 'important');
|
||
el.style.setProperty('pointer-events', 'auto', 'important');
|
||
el.style.setProperty('visibility', 'visible', 'important');
|
||
el.style.setProperty('display', 'block', 'important');
|
||
el.style.setProperty('opacity', '1', 'important');
|
||
el.style.setProperty('clip-path', 'none', 'important');
|
||
|
||
// Move to body if not already there
|
||
if (el.parentElement !== document.body) {
|
||
document.body.appendChild(el);
|
||
}
|
||
} catch (err) {
|
||
console.warn('[OTS] moveToBody failed', err);
|
||
}
|
||
}
|
||
|
||
function applyMenuStyles(el) {
|
||
try {
|
||
if (!el) return;
|
||
el.style.setProperty('position', 'fixed', 'important');
|
||
el.style.setProperty('z-index', '2147483647', 'important');
|
||
el.style.setProperty('transform', 'none', 'important');
|
||
el.style.setProperty('pointer-events', 'auto', 'important');
|
||
} catch (err) {}
|
||
}
|
||
|
||
// Apply to existing menus immediately
|
||
selectors.forEach(sel => {
|
||
document.querySelectorAll(sel).forEach(el => {
|
||
moveToBody(el);
|
||
applyMenuStyles(el);
|
||
});
|
||
});
|
||
|
||
// Continuously guard: check that menus stay in body and have correct styles
|
||
let guardInterval = setInterval(function() {
|
||
try {
|
||
selectors.forEach(sel => {
|
||
document.querySelectorAll(sel).forEach(el => {
|
||
// If menu got moved back, move it to body again
|
||
if (el.parentElement !== document.body && el.parentElement !== null) {
|
||
document.body.appendChild(el);
|
||
}
|
||
// Reapply critical styles in case they got overridden
|
||
applyMenuStyles(el);
|
||
});
|
||
});
|
||
} catch (err) {}
|
||
}, 200);
|
||
|
||
// Keep guard alive for the page lifetime, but stop if no menus found after 30s
|
||
let noMenuCount = 0;
|
||
const checkGuard = setInterval(function() {
|
||
const hasMenus = selectors.some(sel => document.querySelector(sel));
|
||
if (!hasMenus) {
|
||
noMenuCount++;
|
||
if (noMenuCount > 150) {
|
||
clearInterval(guardInterval);
|
||
clearInterval(checkGuard);
|
||
}
|
||
} else {
|
||
noMenuCount = 0;
|
||
}
|
||
}, 200);
|
||
|
||
// Observe for dynamically added menus
|
||
try {
|
||
const mo = new MutationObserver(function(muts) {
|
||
muts.forEach(m => {
|
||
(m.addedNodes || []).forEach(node => {
|
||
try {
|
||
if (!node || node.nodeType !== 1) return;
|
||
selectors.forEach(sel => {
|
||
if (node.matches && node.matches(sel)) {
|
||
moveToBody(node);
|
||
}
|
||
const found = node.querySelectorAll && node.querySelectorAll(sel);
|
||
found && found.forEach(moveToBody);
|
||
});
|
||
} catch (err) {}
|
||
});
|
||
});
|
||
});
|
||
mo.observe(document.body, { childList: true, subtree: true });
|
||
} catch (err) {}
|
||
}
|
||
|
||
/**
|
||
* Initialize search functionality
|
||
*/
|
||
function initSearch() {
|
||
const searchForm = document.querySelector('.topbar-search');
|
||
if (!searchForm) return;
|
||
|
||
const input = searchForm.querySelector('.search-input');
|
||
if (input) {
|
||
input.addEventListener('focus', function() {
|
||
searchForm.style.borderColor = 'var(--color-primary)';
|
||
});
|
||
input.addEventListener('blur', function() {
|
||
searchForm.style.borderColor = 'var(--color-border)';
|
||
});
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Initialize page specific interactions
|
||
*/
|
||
function initPageInteractions() {
|
||
// Displays page - folder selection
|
||
const folderItems = document.querySelectorAll('.folder-item');
|
||
folderItems.forEach(item => {
|
||
item.addEventListener('click', function() {
|
||
folderItems.forEach(f => f.classList.remove('active'));
|
||
this.classList.add('active');
|
||
});
|
||
});
|
||
|
||
// Filter collapse toggle
|
||
const filterCollapseBtn = document.querySelector('#ots-filter-collapse-btn');
|
||
const filterContent = document.querySelector('#ots-filter-content');
|
||
|
||
if (filterCollapseBtn && filterContent) {
|
||
const storageKey = `ots-filter-collapsed:${window.location.pathname}`;
|
||
let isCollapsed = false;
|
||
|
||
filterCollapseBtn.addEventListener('click', function() {
|
||
isCollapsed = !isCollapsed;
|
||
filterContent.classList.toggle('collapsed', isCollapsed);
|
||
|
||
// Rotate icon
|
||
const icon = filterCollapseBtn.querySelector('i');
|
||
icon.classList.toggle('fa-chevron-up');
|
||
icon.classList.toggle('fa-chevron-down');
|
||
|
||
// Save preference to localStorage
|
||
localStorage.setItem(storageKey, isCollapsed);
|
||
});
|
||
|
||
// Restore saved preference
|
||
const savedState = localStorage.getItem(storageKey);
|
||
if (savedState === 'true') {
|
||
isCollapsed = true;
|
||
filterContent.classList.add('collapsed');
|
||
const icon = filterCollapseBtn.querySelector('i');
|
||
icon.classList.remove('fa-chevron-up');
|
||
icon.classList.add('fa-chevron-down');
|
||
} else {
|
||
filterContent.classList.remove('collapsed');
|
||
}
|
||
}
|
||
|
||
// Displays page - folder tree toggle layout
|
||
const folderToggleBtn = document.querySelector('#folder-tree-select-folder-button');
|
||
const folderContainer = document.querySelector('.grid-with-folders-container');
|
||
const folderTree = document.querySelector('#grid-folder-filter');
|
||
|
||
if (folderToggleBtn && folderContainer && folderTree) {
|
||
let debounceTimeout;
|
||
|
||
const syncFolderLayout = () => {
|
||
// Check actual visibility using computed styles
|
||
const computedStyle = window.getComputedStyle(folderTree);
|
||
const isHidden = computedStyle.display === 'none' ||
|
||
computedStyle.visibility === 'hidden' ||
|
||
folderTree.offsetHeight === 0;
|
||
|
||
console.log('Folder collapse sync:', {
|
||
isHidden,
|
||
display: computedStyle.display,
|
||
visibility: computedStyle.visibility,
|
||
offsetHeight: folderTree.offsetHeight
|
||
});
|
||
|
||
folderContainer.classList.toggle('ots-folder-collapsed', !!isHidden);
|
||
|
||
// Log the result
|
||
console.log('Container classes:', folderContainer.className);
|
||
console.log('Grid template columns:', window.getComputedStyle(folderContainer).gridTemplateColumns);
|
||
|
||
// Force reflow
|
||
folderContainer.offsetHeight;
|
||
};
|
||
|
||
const debouncedSync = () => {
|
||
clearTimeout(debounceTimeout);
|
||
debounceTimeout = setTimeout(syncFolderLayout, 50);
|
||
};
|
||
|
||
// Watch for style/class changes on folderTree (let Xibo's code run first)
|
||
const treeObserver = new MutationObserver(() => {
|
||
console.log('Folder tree mutation detected, debouncing sync...');
|
||
debouncedSync();
|
||
});
|
||
treeObserver.observe(folderTree, {
|
||
attributes: true,
|
||
attributeFilter: ['style', 'class']
|
||
});
|
||
|
||
// Initial sync
|
||
syncFolderLayout();
|
||
|
||
// Monitor the folder tree's parent for display changes
|
||
const parentObserver = new MutationObserver(debouncedSync);
|
||
const treeParent = folderTree.parentElement;
|
||
if (treeParent) {
|
||
parentObserver.observe(treeParent, {
|
||
childList: false,
|
||
attributes: true,
|
||
subtree: false
|
||
});
|
||
}
|
||
}
|
||
|
||
// Media page - item selection
|
||
const mediaItems = document.querySelectorAll('.media-item');
|
||
mediaItems.forEach(item => {
|
||
item.addEventListener('click', function() {
|
||
this.style.opacity = '0.7';
|
||
setTimeout(() => this.style.opacity = '1', 200);
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Make sidebar responsive
|
||
*/
|
||
function makeResponsive() {
|
||
const sidebar = document.querySelector('.ots-sidebar');
|
||
const main = document.querySelector('.ots-main');
|
||
|
||
if (!sidebar) return;
|
||
|
||
// Add toggle button for mobile
|
||
if (window.innerWidth <= 768) {
|
||
sidebar.classList.add('mobile');
|
||
}
|
||
|
||
window.addEventListener('resize', function() {
|
||
if (window.innerWidth > 768) {
|
||
sidebar.classList.remove('mobile', 'active');
|
||
} else {
|
||
sidebar.classList.add('mobile');
|
||
}
|
||
// updateSidebarGap() disabled - use CSS variables instead
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Prevent Chart.js errors when chart elements are missing
|
||
*/
|
||
function initChartSafeguard() {
|
||
if (!window.Chart) return;
|
||
|
||
if (typeof window.Chart.acquireContext === 'function') {
|
||
window.Chart.acquireContext = function(item) {
|
||
if (!item) return null;
|
||
|
||
const candidate = item.length ? item[0] : item;
|
||
if (candidate && typeof candidate.getContext === 'function') {
|
||
return candidate.getContext('2d');
|
||
}
|
||
|
||
return null;
|
||
};
|
||
return;
|
||
}
|
||
|
||
if (window.Chart.prototype && typeof window.Chart.prototype.acquireContext === 'function') {
|
||
window.Chart.prototype.acquireContext = function(item) {
|
||
if (!item) return null;
|
||
|
||
const candidate = item.length ? item[0] : item;
|
||
if (candidate && typeof candidate.getContext === 'function') {
|
||
return candidate.getContext('2d');
|
||
}
|
||
|
||
return null;
|
||
};
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Enhance tables: wrap in card, add per-table search box, client-side filtering
|
||
* Non-destructive: skips tables already enhanced
|
||
*/
|
||
function enhanceTables() {
|
||
const selector = '.ots-content table, .content table, .container table, .card table, table';
|
||
const tables = Array.from(document.querySelectorAll(selector));
|
||
let counter = 0;
|
||
|
||
tables.forEach(table => {
|
||
// only enhance tables that have a thead and tbody
|
||
if (!table || table.classList.contains('modern-table')) return;
|
||
if (!table.querySelector('thead') || !table.querySelector('tbody')) return;
|
||
|
||
counter += 1;
|
||
table.classList.add('modern-table');
|
||
|
||
// Build wrapper structure
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'modern-table-card';
|
||
|
||
const controls = document.createElement('div');
|
||
controls.className = 'table-controls';
|
||
|
||
const input = document.createElement('input');
|
||
input.type = 'search';
|
||
input.placeholder = 'Search…';
|
||
input.className = 'table-search-input';
|
||
input.setAttribute('aria-label', 'Table search');
|
||
|
||
controls.appendChild(input);
|
||
|
||
const tableWrapper = document.createElement('div');
|
||
tableWrapper.className = 'table-wrapper';
|
||
tableWrapper.style.overflow = 'auto';
|
||
|
||
// Insert wrapper into DOM in place of the table
|
||
const parent = table.parentNode;
|
||
parent.replaceChild(wrapper, table);
|
||
wrapper.appendChild(controls);
|
||
wrapper.appendChild(tableWrapper);
|
||
tableWrapper.appendChild(table);
|
||
|
||
// Simple, light-weight search filtering for this table only
|
||
input.addEventListener('input', function (e) {
|
||
const term = (e.target.value || '').toLowerCase();
|
||
table.querySelectorAll('tbody tr').forEach(tr => {
|
||
const text = tr.textContent.toLowerCase();
|
||
tr.style.display = term === '' || text.includes(term) ? '' : 'none';
|
||
});
|
||
});
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initialize DataTables for enhanced behavior when available.
|
||
* Falls back gracefully if DataTables or jQuery are not present.
|
||
*/
|
||
function initDataTables() {
|
||
if (!window.jQuery) return;
|
||
const $ = window.jQuery;
|
||
if (!$.fn || !$.fn.dataTable) return;
|
||
|
||
// Skip Xibo-managed grids to avoid double initialization
|
||
if (document.querySelector('.XiboGrid')) return;
|
||
|
||
$('.modern-table, table').each(function () {
|
||
try {
|
||
if (this.closest('.XiboGrid')) return;
|
||
if (!$.fn.dataTable.isDataTable(this)) {
|
||
$(this).DataTable({
|
||
responsive: true,
|
||
lengthChange: false,
|
||
pageLength: 10,
|
||
autoWidth: false,
|
||
dom: '<"table-controls"f>rt<"table-meta"ip>',
|
||
language: { search: '' }
|
||
});
|
||
}
|
||
} catch (err) {
|
||
// If initialization fails, ignore and allow fallback enhancer
|
||
console.warn('DataTables init failed for table', this, err);
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initialize light/dark mode toggle
|
||
*/
|
||
function initThemeToggle() {
|
||
const themeToggle = document.getElementById('ots-theme-toggle');
|
||
if (!themeToggle) return;
|
||
|
||
const storedTheme = localStorage.getItem('ots-theme-mode');
|
||
const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
|
||
const effectiveTheme = storedTheme || (prefersLight ? 'light' : 'dark');
|
||
const body = document.body;
|
||
const root = document.documentElement;
|
||
|
||
// Apply stored theme on page load (apply to both <html> and <body>)
|
||
if (effectiveTheme === 'light') {
|
||
body.classList.add('ots-light-mode');
|
||
root.classList.add('ots-light-mode');
|
||
updateThemeLabel();
|
||
}
|
||
|
||
// Toggle on click (keep <html> in sync so :root variables reflect mode)
|
||
themeToggle.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
const isLight = body.classList.toggle('ots-light-mode');
|
||
root.classList.toggle('ots-light-mode', isLight);
|
||
localStorage.setItem('ots-theme-mode', isLight ? 'light' : 'dark');
|
||
updateThemeLabel();
|
||
});
|
||
|
||
function updateThemeLabel() {
|
||
const icon = document.getElementById('ots-theme-icon');
|
||
const label = document.getElementById('ots-theme-label');
|
||
const isLight = body.classList.contains('ots-light-mode');
|
||
if (icon) {
|
||
icon.className = isLight ? 'fa fa-sun-o' : 'fa fa-moon-o';
|
||
}
|
||
if (label) {
|
||
label.textContent = isLight ? 'Light Mode' : 'Dark Mode';
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* 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.
|
||
*/
|
||
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;
|
||
}
|
||
|
||
// 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();
|
||
}
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Initialize all features when DOM is ready
|
||
*/
|
||
function init() {
|
||
initSidebarToggle();
|
||
initSidebarSectionToggles();
|
||
buildFlyoutHeaders();
|
||
initThemeToggle();
|
||
initDropdowns();
|
||
initRowDropdowns();
|
||
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();
|
||
}
|
||
})();
|
||
|
||
// 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);
|
||
}
|