/** * 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' }; /** * Measure sidebar width and set CSS variable for layout */ function updateSidebarWidth() { const sidebar = document.querySelector('.ots-sidebar'); if (!sidebar) return; const collapsed = sidebar.classList.contains('collapsed'); // If called with a forced mode, use the stored defaults const forceMode = updateSidebarWidth._forceMode || null; const base = (forceMode === 'full') ? (window.__otsFullSidebarWidth || 256) : (forceMode === 'collapsed') ? (window.__otsCollapsedSidebarWidth || 70) : (collapsed ? 70 : sidebar.offsetWidth || sidebar.getBoundingClientRect().width || 240); const padding = 5; const value = Math.max(70, Math.round(base + padding)); // Apply CSS variable used by layout and also set an inline width fallback document.documentElement.style.setProperty('--ots-sidebar-width', `${value}px`); try { // Inline width helps force an immediate reflow when CSS rules/important flags interfere // Use setProperty with 'important' so stylesheet !important rules can't override it. sidebar.style.setProperty('width', `${value}px`, 'important'); // Force reflow to encourage the browser to apply the new sizing immediately // eslint-disable-next-line no-unused-expressions sidebar.offsetHeight; } catch (err) { try { sidebar.style.width = `${value}px`; } catch (e) { /* ignore */ } } // Debug logging to help identify timing/specifity issues in the wild if (window.__otsDebug) { console.log('[OTS] updateSidebarWidth', { collapsed, base, value, cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width') }); } } // Helper to request a forced width update function forceSidebarWidthMode(mode) { updateSidebarWidth._forceMode = mode; // 'full' | 'collapsed' | null updateSidebarWidth(); updateSidebarWidth._forceMode = null; } /** * Measure the sidebar header bottom and set the top padding of the nav list * so nav items always begin below the header (logo + buttons). */ function updateSidebarNavOffset() { const sidebar = document.querySelector('.ots-sidebar'); if (!sidebar) return; const header = sidebar.querySelector('.sidebar-header'); const nav = sidebar.querySelector('.sidebar-nav, .ots-sidebar-nav'); if (!nav) return; const sidebarRect = sidebar.getBoundingClientRect(); const headerRect = header ? header.getBoundingClientRect() : null; let offset = 0; if (headerRect) { offset = Math.max(0, Math.ceil(headerRect.bottom - sidebarRect.top)); } else if (header) { offset = header.offsetHeight || 0; } const gap = 8; const paddingTop = offset > 0 ? offset + gap : ''; if (paddingTop) { try { nav.style.setProperty('padding-top', `${paddingTop}px`, 'important'); } catch (err) { nav.style.paddingTop = `${paddingTop}px`; } if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset applied', { paddingTop }); } else { try { nav.style.removeProperty('padding-top'); } catch (err) { nav.style.paddingTop = ''; } if (window.__otsDebug) console.log('[OTS] updateSidebarNavOffset cleared'); } } /** * Measure the sidebar and set an explicit left margin on the page wrapper * so the gap between the sidebar and page content is exactly 5px. */ function updateSidebarGap() { const sidebar = document.querySelector('.ots-sidebar'); // target likely content containers in this app const targets = [ document.getElementById('page-wrapper'), document.querySelector('.ots-main'), document.getElementById('content-wrapper'), document.querySelector('#content') ].filter(Boolean); if (!sidebar || !targets.length) return; const gap = (typeof window.__otsDesiredSidebarGap !== 'undefined') ? Number(window.__otsDesiredSidebarGap) : 0; // desired gap in px (default 0) const rect = sidebar.getBoundingClientRect(); // desired inner left padding (allows trimming space inside the content area) const desiredInnerPadding = (typeof window.__otsDesiredPagePaddingLeft !== 'undefined') ? Number(window.__otsDesiredPagePaddingLeft) : 8; targets.forEach(pageWrapper => { const pageRect = pageWrapper.getBoundingClientRect(); const computed = window.getComputedStyle(pageWrapper); const currentMargin = parseFloat(computed.marginLeft) || 0; const currentGap = Math.round(pageRect.left - rect.right); // Calculate how much to adjust margin-left so gap becomes `gap`. const delta = currentGap - gap; const newMargin = Math.max(0, Math.round(currentMargin - delta)); try { pageWrapper.style.setProperty('margin-left', `${newMargin}px`, 'important'); pageWrapper.style.setProperty('padding-left', `${desiredInnerPadding}px`, 'important'); } catch (err) { pageWrapper.style.marginLeft = `${newMargin}px`; pageWrapper.style.paddingLeft = `${desiredInnerPadding}px`; } // Also adjust common child wrapper padding if present try { const inner = pageWrapper.querySelector('.page-content') || pageWrapper.querySelector('.ots-content') || pageWrapper.querySelector('.container'); if (inner) inner.style.setProperty('padding-left', `${desiredInnerPadding}px`, 'important'); } catch (err) {} if (window.__otsDebug) console.log('[OTS] updateSidebarGap', { target: pageWrapper.tagName + (pageWrapper.id ? '#'+pageWrapper.id : ''), sidebarWidth: rect.width, sidebarRight: rect.right, pageLeft: pageRect.left, currentGap, newMargin }); // Detect narrow intervening elements (visual separator) and neutralize their visuals try { const sampleXs = [Math.round(rect.right + 2), Math.round((rect.right + pageRect.left) / 2), Math.round(pageRect.left - 2)]; const ys = [Math.floor(window.innerHeight / 2), Math.floor(window.innerHeight / 4), Math.floor(window.innerHeight * 0.75)]; const seen = new Set(); sampleXs.forEach(x => { ys.forEach(y => { try { const els = document.elementsFromPoint(x, y) || []; els.forEach(el => { if (!el || el === document.documentElement || el === document.body) return; if (el === sidebar || el === pageWrapper) return; const b = el.getBoundingClientRect(); // narrow vertical candidates between sidebar and content if (b.left >= rect.right - 4 && b.right <= pageRect.left + 4 && b.width <= 80 && b.height >= 40) { const id = el.tagName + (el.id ? '#'+el.id : '') + (el.className ? '.'+el.className.split(' ').join('.') : ''); if (seen.has(id)) return; seen.add(id); try { el.style.setProperty('background', 'transparent', 'important'); el.style.setProperty('background-image', 'none', 'important'); el.style.setProperty('box-shadow', 'none', 'important'); el.style.setProperty('border', 'none', 'important'); el.style.setProperty('pointer-events', 'none', 'important'); if (window.__otsDebug) console.log('[OTS] neutralized intervening element', { id, rect: b }); } catch (err) {} } }); } catch (err) {} }); }); } 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; if (toggleBtn) { toggleBtn.addEventListener('click', function(e) { e.preventDefault(); sidebar.classList.toggle('active'); }); } if (collapseBtn) { const isCollapsed = localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true'; if (isCollapsed) { sidebar.classList.add('collapsed'); body.classList.add('ots-sidebar-collapsed'); updateSidebarStateClass(); updateSidebarGap(); } collapseBtn.addEventListener('click', function(e) { e.preventDefault(); const nowCollapsed = !sidebar.classList.contains('collapsed'); sidebar.classList.toggle('collapsed'); body.classList.toggle('ots-sidebar-collapsed', nowCollapsed); localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, nowCollapsed ? 'true' : 'false'); // Force collapsed width immediately forceSidebarWidthMode('collapsed'); // Recalculate nav offset so items remain below header after collapse updateSidebarNavOffset(); // Ensure page content gap is updated for collapsed width updateSidebarGap(); // Re-run shortly after to catch any late layout changes setTimeout(updateSidebarGap, 80); updateSidebarStateClass(); // Debug state after toggle try { console.log('[OTS] collapseBtn clicked', { nowCollapsed, classes: sidebar.className, inlineStyle: sidebar.getAttribute('style'), computedWidth: getComputedStyle(sidebar).width, cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width') }); } catch (err) {} }); } if (expandBtn) { expandBtn.addEventListener('click', function(e) { e.preventDefault(); sidebar.classList.remove('collapsed'); body.classList.remove('ots-sidebar-collapsed'); localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false'); // Force full width when expanding forceSidebarWidthMode('full'); // Recalculate nav offset after expanding updateSidebarNavOffset(); // Ensure page content gap is updated for expanded width updateSidebarGap(); setTimeout(updateSidebarGap, 80); updateSidebarStateClass(); try { console.log('[OTS] expandBtn clicked', { classes: sidebar.className, inlineStyle: sidebar.getAttribute('style'), computedWidth: getComputedStyle(sidebar).width, cssVar: getComputedStyle(document.documentElement).getPropertyValue('--ots-sidebar-width') }); } catch (err) {} }); } // Close sidebar when clicking outside on mobile document.addEventListener('click', function(e) { if (window.innerWidth <= 768) { const isClickInsideSidebar = sidebar.contains(e.target); const isClickOnToggle = toggleBtn && toggleBtn.contains(e.target); if (!isClickInsideSidebar && !isClickOnToggle && sidebar.classList.contains('active')) { sidebar.classList.remove('active'); updateSidebarStateClass(); } } }); // Ensure initial state class is set updateSidebarStateClass(); } /** * Initialize sidebar section collapse/expand functionality */ function initSidebarSectionToggles() { const groupToggles = document.querySelectorAll('.sidebar-group-toggle'); groupToggles.forEach(toggle => { const group = toggle.closest('.sidebar-group'); const submenu = group ? group.querySelector('.sidebar-submenu') : null; const caret = toggle.querySelector('.sidebar-group-caret'); if (submenu) { const isOpen = group.classList.contains('is-open'); submenu.style.display = isOpen ? 'block' : 'none'; toggle.setAttribute('aria-expanded', isOpen.toString()); } toggle.addEventListener('click', function(e) { e.preventDefault(); const group = toggle.closest('.sidebar-group'); const submenu = group ? group.querySelector('.sidebar-submenu') : null; if (!submenu) return; const isOpen = group.classList.contains('is-open'); group.classList.toggle('is-open', !isOpen); toggle.setAttribute('aria-expanded', (!isOpen).toString()); submenu.style.display = isOpen ? 'none' : 'block'; }); if (caret) { caret.addEventListener('click', function(e) { e.preventDefault(); e.stopPropagation(); toggle.click(); }); } }); // Capture-phase handler to override any conflicting listeners document.addEventListener('click', function(e) { const caret = e.target.closest('.sidebar-group-caret'); const toggle = e.target.closest('.sidebar-group-toggle'); const target = toggle || (caret ? caret.closest('.sidebar-group-toggle') : null); if (!target) return; e.preventDefault(); e.stopPropagation(); const group = target.closest('.sidebar-group'); const submenu = group ? group.querySelector('.sidebar-submenu') : null; if (!submenu) return; const isOpen = group.classList.contains('is-open'); group.classList.toggle('is-open', !isOpen); target.setAttribute('aria-expanded', (!isOpen).toString()); submenu.style.display = isOpen ? 'none' : 'block'; }, true); } /** * Initialize dropdown menus */ function initDropdowns() { const dropdowns = document.querySelectorAll('.dropdown'); dropdowns.forEach(dropdown => { const button = dropdown.querySelector('.dropdown-menu'); if (!button) return; const menu = dropdown.querySelector('.dropdown-menu'); // Toggle menu on button click dropdown.addEventListener('click', function(e) { if (e.target.closest('.user-btn') || e.target.closest('[aria-label="User menu"]') || e.target.closest('#navbarUserMenu')) { e.preventDefault(); dropdown.classList.toggle('active'); // If this dropdown contains the user menu, compute placement to avoid going off-screen const menu = dropdown.querySelector('.dropdown-menu.ots-user-menu'); const trigger = dropdown.querySelector('#navbarUserMenu'); if (menu && trigger) { // Reset any previous placement classes menu.classList.remove('dropdown-menu-left'); menu.classList.remove('dropdown-menu-right'); // Use getBoundingClientRect for accurate placement const trigRect = trigger.getBoundingClientRect(); // Ensure menu is in DOM and has an offsetWidth const menuWidth = menu.offsetWidth || 220; // fallback estimate const spaceRight = window.innerWidth - trigRect.right; const spaceLeft = trigRect.left; // Prefer opening to the right where possible, otherwise open to the left if (spaceRight < menuWidth && spaceLeft > menuWidth) { // not enough space on the right, open to left menu.classList.add('dropdown-menu-left'); } else { // default to right-aligned menu.classList.add('dropdown-menu-right'); } } } }); // Close menu when clicking outside document.addEventListener('click', function(e) { if (!dropdown.contains(e.target)) { dropdown.classList.remove('active'); } }); }); } /** * 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'); } updateSidebarWidth(); updateSidebarGap(); }); } /** * 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'); input.style.minWidth = '180px'; controls.appendChild(input); const tableWrapper = document.createElement('div'); tableWrapper.className = 'table-wrapper'; tableWrapper.style.overflow = 'auto'; // Insert wrapper into DOM in place of the table const parent = table.parentNode; parent.replaceChild(wrapper, table); wrapper.appendChild(controls); wrapper.appendChild(tableWrapper); tableWrapper.appendChild(table); // Simple, light-weight search filtering for this table only input.addEventListener('input', function (e) { const term = (e.target.value || '').toLowerCase(); table.querySelectorAll('tbody tr').forEach(tr => { const text = tr.textContent.toLowerCase(); tr.style.display = term === '' || text.includes(term) ? '' : 'none'; }); }); }); } /** * Initialize DataTables for enhanced behavior when available. * Falls back gracefully if DataTables or jQuery are not present. */ function initDataTables() { if (!window.jQuery) return; const $ = window.jQuery; if (!$.fn || !$.fn.dataTable) return; // Skip Xibo-managed grids to avoid double initialization if (document.querySelector('.XiboGrid')) return; $('.modern-table, table').each(function () { try { if (this.closest('.XiboGrid')) return; if (!$.fn.dataTable.isDataTable(this)) { $(this).DataTable({ responsive: true, lengthChange: false, pageLength: 10, autoWidth: false, dom: '<"table-controls"f>rt<"table-meta"ip>', language: { search: '' } }); } } catch (err) { // If initialization fails, ignore and allow fallback enhancer console.warn('DataTables init failed for table', this, err); } }); } /** * Initialize light/dark mode toggle */ function initThemeToggle() { const themeToggle = document.getElementById('ots-theme-toggle'); if (!themeToggle) return; const storedTheme = localStorage.getItem('ots-theme-mode'); const prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches; const effectiveTheme = storedTheme || (prefersLight ? 'light' : 'dark'); const body = document.body; const root = document.documentElement; // Apply stored theme on page load (apply to both and ) if (effectiveTheme === 'light') { body.classList.add('ots-light-mode'); root.classList.add('ots-light-mode'); updateThemeLabel(); } // Toggle on click (keep in sync so :root variables reflect mode) themeToggle.addEventListener('click', function(e) { e.preventDefault(); const isLight = body.classList.toggle('ots-light-mode'); root.classList.toggle('ots-light-mode', isLight); localStorage.setItem('ots-theme-mode', isLight ? 'light' : 'dark'); updateThemeLabel(); }); function updateThemeLabel() { const icon = document.getElementById('ots-theme-icon'); const label = document.getElementById('ots-theme-label'); const isLight = body.classList.contains('ots-light-mode'); if (icon) { icon.className = isLight ? 'fa fa-sun-o' : 'fa fa-moon-o'; } if (label) { label.textContent = isLight ? 'Light Mode' : 'Dark Mode'; } } } /** * Initialize all features when DOM is ready */ function init() { initSidebarToggle(); initSidebarSectionToggles(); initThemeToggle(); initDropdowns(); initSearch(); initPageInteractions(); initDataTables(); enhanceTables(); makeResponsive(); initChartSafeguard(); updateSidebarWidth(); updateSidebarNavOffset(); updateSidebarGap(); var debouncedUpdate = debounce(function() { updateSidebarNavOffset(); updateSidebarWidth(); updateSidebarGap(); }, 120); window.addEventListener('resize', debouncedUpdate); } // Wait for DOM to be ready if (document.readyState === 'loading') { document.addEventListener('DOMContentLoaded', init); } else { init(); } })();