- Updated filter panel toggle icons from chevron-up to chevron-down across multiple pages for consistency. - Added 'collapsed' class to filter content divs to manage visibility state. - Enhanced library page button for tidying up media items, replacing the trash icon with a custom SVG broom icon. - Improved CSS styles for sidebar and page header to ensure visibility and proper layout when the sidebar is collapsed. - Introduced JavaScript functionality to manage sidebar width and state, including theme toggle for light/dark mode. - Created a new notification drawer template that adapts based on the compact view state.
786 lines
28 KiB
Twig
786 lines
28 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'
|
|
};
|
|
|
|
/**
|
|
* 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 <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';
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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();
|
|
}
|
|
})();
|