Compare commits

..

1 Commits

Author SHA1 Message Date
Matt Batchelder
0acd5d4ab6 Remove user group and welcome page templates from the OTS Signs theme 2026-04-11 14:05:17 -04:00
57 changed files with 756 additions and 41821 deletions

View File

@@ -8,7 +8,7 @@
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
@@ -17,20 +17,20 @@
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
*/
defined('XIBO') or die("Sorry, you are not allowed to directly access this page.<br /> Please press the back button in your browser.");
$config = array(
'theme_name' => 'otssigns',
'theme_title' => 'OTS Signs',
'app_name' => 'OTS Signage',
'theme_url' => 'CMS Homepage',
'cms_source_url' => 'https://github.com/xibosignage/xibo-cms',
'cms_install_url' => 'manual/en/install_cms.html',
'cms_release_notes_url' => 'manual/en/release_notes.html',
'latest_news_url' => 'http://xibo.org.uk/feed/',
'client_sendCurrentLayoutAsStatusUpdate_enabled' => false,
'client_screenShotRequestInterval_enabled' => false,
"view_path" => "../web/theme/custom/ots-signs/views",
'product_support_url' => 'https://community.xibo.org.uk/c/support'
);
'theme_name' => 'ots-signs',
'theme_title' => 'OTS Signs',
'app_name' => 'OTS Signage',
'theme_url' => 'CMS Homepage',
'cms_source_url' => 'https://github.com/xibosignage/xibo-cms',
'cms_install_url' => 'manual/en/install_cms.html',
'cms_release_notes_url' => 'manual/en/release_notes.html',
'latest_news_url' => 'http://xibo.org.uk/feed/',
'client_sendCurrentLayoutAsStatusUpdate_enabled' => false,
'client_screenShotRequestInterval_enabled' => false,
'view_path' => '../web/theme/custom/ots-signs/views',
'product_support_url' => 'https://community.xibo.org.uk/c/support'
);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

View File

@@ -1,774 +0,0 @@
/**
* OTS Signage Modern Theme - Client-Side Utilities
* Sidebar toggle, dropdown menus, and UI interactions
*/
(function() {
'use strict';
const STORAGE_KEYS = {
sidebarCollapsed: 'otsTheme:sidebarCollapsed'
};
/**
* Initialize sidebar toggle functionality
*/
function initSidebarToggle() {
const toggleBtn = document.querySelector('[data-action="toggle-sidebar"]');
const sidebar = document.querySelector('.ots-sidebar');
const closeBtn = document.querySelector('.ots-sidebar-close');
const collapseBtn = document.querySelector('.sidebar-collapse-btn-visible');
const expandBtn = document.querySelector('.sidebar-expand-btn');
const body = document.body;
if (!sidebar) return;
// Handle sidebar close button
if (closeBtn) {
closeBtn.addEventListener('click', function(e) {
e.preventDefault();
sidebar.classList.remove('active');
});
}
// 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;
// move focus into the sidebar
const firstFocusable = sidebar.querySelector('button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])');
if (firstFocusable) firstFocusable.focus(); else sidebar.setAttribute('tabindex', '-1'), sidebar.focus();
// add escape handler
document.addEventListener('keydown', escHandler);
} else {
bd.classList.remove('show');
toggleBtn.setAttribute('aria-expanded', 'false');
if (lastFocus) lastFocus.focus();
document.removeEventListener('keydown', escHandler);
}
}
});
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) {
let isCollapsed = false;
try { isCollapsed = localStorage.getItem(STORAGE_KEYS.sidebarCollapsed) === 'true'; } catch(e) {}
if (isCollapsed) {
sidebar.classList.add('collapsed');
body.classList.add('ots-sidebar-collapsed');
document.documentElement.classList.add('ots-sidebar-collapsed');
updateSidebarStateClass();
}
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);
try { localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, nowCollapsed ? 'true' : 'false'); } catch(e) {}
syncSubmenuDisplayForState(nowCollapsed);
updateSidebarNavOffset();
updateSidebarStateClass();
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');
try { localStorage.setItem(STORAGE_KEYS.sidebarCollapsed, 'false'); } catch(e) {}
syncSubmenuDisplayForState(false);
updateSidebarNavOffset();
updateSidebarStateClass();
updateSidebarWidth();
setTimeout(updateSidebarWidth, 250);
});
}
// Initialize sidebar section toggles
initSidebarSectionToggles();
// Inject flyout headers (icon + label) into each submenu for collapsed state
buildFlyoutHeaders();
// 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');
}
}
});
}
/**
* 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 = ''; }
}
}
// simple debounce helper
function debounce(fn, wait) {
let t;
return function () {
clearTimeout(t);
t = setTimeout(() => fn.apply(this, arguments), wait);
};
}
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');
}
}
/**
* 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;
// Don't inject twice
if (submenu.querySelector('.flyout-header')) return;
var toggle = group.querySelector('.sidebar-group-toggle');
if (!toggle) return;
// Grab the icon element's class list and the label text
var iconEl = toggle.querySelector('.ots-nav-icon');
var textEl = toggle.querySelector('.ots-nav-text');
if (!textEl) return;
var label = textEl.textContent.trim();
// Build the header <li>
var header = document.createElement('li');
header.className = 'flyout-header';
header.setAttribute('aria-hidden', 'true');
// Clone the icon
if (iconEl) {
var icon = document.createElement('span');
icon.className = iconEl.className; // copies all fa classes
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) {
// Remove inline display so CSS visibility/opacity hover rules work
submenu.style.removeProperty('display');
} else {
// Expanded mode: show/hide based on is-open class
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');
syncSidebarActiveStates();
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');
// Only set inline display when sidebar is NOT collapsed;
// collapsed state uses CSS :hover to show flyout menus.
const sidebarEl = document.querySelector('.ots-sidebar');
const isCollapsed = sidebarEl && sidebarEl.classList.contains('collapsed');
if (!isCollapsed) {
submenu.style.display = isOpen ? 'block' : 'none';
} else {
// Clear any leftover inline display so CSS :hover can work
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');
// When collapsed, don't toggle submenus on click — hover handles it
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';
syncSidebarActiveStates();
});
if (caret) {
caret.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
toggle.click();
});
}
});
}
function syncSidebarActiveStates() {
const groups = document.querySelectorAll('.sidebar-group');
groups.forEach(group => {
const toggle = group.querySelector('.sidebar-group-toggle');
if (!toggle) return;
const hasActiveChild = Boolean(
group.querySelector('.sidebar-list.active') ||
group.querySelector('.sidebar-list > a.active')
);
toggle.classList.toggle('active', hasActiveChild);
if (hasActiveChild) {
group.classList.add('is-open');
const submenu = group.querySelector('.sidebar-submenu');
if (submenu) submenu.style.display = 'block';
toggle.setAttribute('aria-expanded', 'true');
}
});
}
/**
* Initialize dropdown menus
*/
function initDropdowns() {
// Only handle OTS-specific dropdowns (notifications, etc.).
// The user menu (#navbarUserMenu) is handled by theme-scripts.twig's
// initDropdowns() which uses floatMenu() for proper positioning.
// Let Bootstrap handle topbar nav dropdowns (Schedule, Design, etc.) natively
// so that links like Dayparting can navigate normally.
const otsDropdowns = Array.from(
document.querySelectorAll('.ots-topbar-action .dropdown, .ots-page-actions .dropdown')
).filter(function(el) {
// Exclude the user menu — it has its own dedicated handler in theme-scripts.twig
return !el.querySelector('#navbarUserMenu');
});
otsDropdowns.forEach(dropdown => {
const toggle = dropdown.querySelector('.dropdown-toggle, [data-toggle="dropdown"]');
const menu = dropdown.querySelector('.dropdown-menu');
if (!toggle || !menu) return;
// Toggle menu on toggle click
toggle.addEventListener('click', function(e) {
e.preventDefault();
e.stopPropagation();
const isNowActive = dropdown.classList.toggle('active');
// Close other OTS dropdowns
otsDropdowns.forEach(other => {
if (other !== dropdown) other.classList.remove('active');
});
});
// Close menu when clicking outside
document.addEventListener('click', function(e) {
if (!dropdown.contains(e.target)) {
dropdown.classList.remove('active');
}
});
});
// Support DataTables Buttons collections which are not wrapped by .dropdown
document.addEventListener('click', function(e) {
const btn = e.target.closest('.dt-button');
if (btn) {
e.preventDefault();
e.stopPropagation();
const wrapper = btn.closest('.dt-buttons') || btn.parentElement;
// close other open dt-buttons collections
document.querySelectorAll('.dt-buttons.active').forEach(w => {
if (w !== wrapper) w.classList.remove('active');
});
wrapper.classList.toggle('active');
// If DataTables placed the collection on the body, find it and position it under the clicked button
const allCollections = Array.from(document.querySelectorAll('.dt-button-collection'));
let collection = wrapper.querySelector('.dt-button-collection') || allCollections.find(c => !wrapper.contains(c));
// If DataTables didn't create a collection element, create one as a fallback
if (!collection) {
collection = document.createElement('div');
collection.className = 'dt-button-collection';
// prefer to append near wrapper for positioning; fallback to body
(wrapper || document.body).appendChild(collection);
}
if (collection) {
// hide other collections
allCollections.forEach(c => { if (c !== collection) { c.classList.remove('show'); c.style.display = 'none'; } });
const rect = btn.getBoundingClientRect();
const top = rect.bottom + window.scrollY;
const left = rect.left + window.scrollX;
collection.style.position = 'absolute';
collection.style.top = `${top}px`;
collection.style.left = `${left}px`;
collection.style.display = 'block';
collection.classList.add('show');
// If the collection is empty or visually empty, build a fallback column list from the nearest table
const isEmpty = collection.children.length === 0 || collection.textContent.trim() === '' || collection.offsetHeight < 10;
if (isEmpty) {
try {
let table = btn.closest('table') || wrapper.querySelector('table') || document.querySelector('table');
if (table && window.jQuery && jQuery.fn && jQuery.fn.dataTable && jQuery.fn.dataTable.isDataTable(table)) {
const dt = jQuery(table).DataTable();
// clear existing
collection.innerHTML = '';
const thead = table.querySelectorAll('thead th');
thead.forEach((th, idx) => {
const text = (th.textContent || th.innerText || `Column ${idx+1}`).trim();
const item = document.createElement('div');
item.style.padding = '6px 12px';
item.style.display = 'flex';
item.style.alignItems = 'center';
item.style.gap = '8px';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = dt.column(idx).visible();
checkbox.addEventListener('change', function() {
dt.column(idx).visible(this.checked);
});
const label = document.createElement('span');
label.textContent = text;
label.style.color = 'var(--color-text-primary)';
item.appendChild(checkbox);
item.appendChild(label);
collection.appendChild(item);
});
} else {
// no DataTable instance found
}
} catch (err) {
// column list fallback failed
}
}
}
return;
}
// click outside dt-button -> close any open collections
document.querySelectorAll('.dt-buttons.active').forEach(w => w.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');
});
});
// 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');
}
// Recompute sidebar width on resize
updateSidebarWidth();
});
}
/**
* 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;
$('.modern-table, table').each(function () {
try {
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);
}
});
}
/**
* Close every open dropdown / popover on the page.
* Covers: Bootstrap 4 native dropdowns, OTS custom dropdowns,
* the user-menu, notification drawer, and DataTable row menus.
*/
function closeAllDropdowns() {
try {
// Row dropdown menus appended to body
document.querySelectorAll('.ots-row-dropdown').forEach(function(m) {
m.classList.remove('show', 'ots-row-dropdown');
m.style.cssText = '';
});
document.querySelectorAll('.dropdown.show, .btn-group.show').forEach(function(el) {
el.classList.remove('show');
var m = el.querySelector('.dropdown-menu.show');
if (m) m.classList.remove('show');
});
document.querySelectorAll('.dropdown-menu.show').forEach(function(m) {
m.classList.remove('show');
});
document.querySelectorAll('.dropdown.active, .ots-topbar-action .dropdown.active, .ots-page-actions .dropdown.active').forEach(function(el) {
el.classList.remove('active');
});
var userMenu = document.querySelector('.ots-user-menu-body.ots-user-menu-open');
if (userMenu) {
userMenu.classList.remove('ots-user-menu-open');
var userToggle = document.querySelector('#navbarUserMenu');
if (userToggle) {
var dd = userToggle.closest('.dropdown');
if (dd) dd.classList.remove('active', 'show');
}
}
document.querySelectorAll('.dt-buttons.active').forEach(function(w) { w.classList.remove('active'); });
document.querySelectorAll('.dt-button-collection.show').forEach(function(c) { c.classList.remove('show'); c.style.display = 'none'; });
if (window.jQuery) {
window.jQuery('.dropdown-toggle[aria-expanded="true"]').attr('aria-expanded', 'false');
window.jQuery('.dropdown-menu.show').removeClass('show');
window.jQuery('.dropdown.show, .btn-group.show').removeClass('show');
}
} catch (err) {}
}
/**
* Wire up global listeners that trigger closeAllDropdowns().
*/
function initGlobalDropdownDismiss() {
document.addEventListener('show.bs.modal', closeAllDropdowns, true);
try {
if (window.jQuery) {
window.jQuery(document).on('show.bs.modal', closeAllDropdowns);
window.jQuery(document).on('click', '.XiboFormButton, .XiboAjaxSubmit, .XiboFormRender', function() {
closeAllDropdowns();
});
}
} catch (e) {}
document.addEventListener('click', function(e) {
var link = e.target.closest('.dropdown-menu a, .ots-topbar a.nav-link, .ots-topbar a.dropdown-item, .XiboFormButton');
if (link && !e.defaultPrevented) {
closeAllDropdowns();
}
});
window.addEventListener('popstate', closeAllDropdowns);
try {
var origPush = history.pushState;
var origReplace = history.replaceState;
history.pushState = function() { origPush.apply(this, arguments); closeAllDropdowns(); };
history.replaceState = function() { origReplace.apply(this, arguments); closeAllDropdowns(); };
} catch (e) {}
try {
var content = document.getElementById('content') || document.querySelector('.ots-content');
if (content) {
var contentObs = new MutationObserver(debounce(closeAllDropdowns, 80));
contentObs.observe(content, { childList: true });
}
} catch (e) {}
}
/**
* Initialize all features when DOM is ready
*/
function init() {
initSidebarToggle();
initDropdowns();
initGlobalDropdownDismiss();
initSearch();
initPageInteractions();
initDataTables();
enhanceTables();
makeResponsive();
initChartSafeguard();
// Set initial sidebar width variable and keep it updated
updateSidebarWidth();
// Set initial nav offset and keep it updated on resize
updateSidebarNavOffset();
const debouncedUpdateNavOffset = debounce(function() {
updateSidebarNavOffset();
updateSidebarWidth();
}, 120);
window.addEventListener('resize', debouncedUpdateNavOffset);
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();

View File

@@ -161,48 +161,15 @@
var link = document.getElementById("fallback-link");
if (link) link.href = destination;
var spinner = document.getElementById("spinner");
var checkmark = document.getElementById("checkmark");
var message = document.getElementById("message");
// Check CMS web session auth by fetching the CMS root and following redirects.
// - Unauthenticated: 302 → /login (final response.url contains "/login")
// - Authenticated: 302 → /dashboard (final response.url does NOT contain "/login")
var cmsRootUrl = window.location.origin + cmsBase + "/";
fetch(cmsRootUrl, {
method: "GET",
credentials: "include" // sends the CMS session cookie; follow redirects (default)
})
.then(function (response) {
var finalUrl = response.url || "";
// Not authenticated if redirected outside the CMS base path (e.g. to a SAML IdP
// at /auth/… the same origin) or to a known CMS auth page (/login, /saml/…).
var expectedBase = window.location.origin + cmsBase;
var isOffBase = cmsBase !== "" && !finalUrl.startsWith(expectedBase);
var isAuthPage = finalUrl.indexOf("/login") !== -1 || finalUrl.indexOf("/saml") !== -1;
if (!finalUrl || isOffBase || isAuthPage) {
throw new Error("unauthenticated");
}
return response;
})
.then(function () {
// Authenticated — show the green checkmark for 2 seconds then redirect
spinner.style.display = "none";
checkmark.classList.add("visible");
message.textContent = "Auth to CMS";
if (link) link.style.display = "inline";
setTimeout(function () {
window.location.replace(destination);
}, 2000);
})
.catch(function () {
// Not authenticated — send to the CMS login page, preserving the return URL
var returnUrl = encodeURIComponent(window.location.href);
var loginUrl = window.location.origin + cmsBase + "/login?redirect=" + returnUrl;
window.location.replace(loginUrl);
});
// Redirect directly to the destination.
//
// If the user is already authenticated, Xibo serves the page immediately.
// If not, Xibo's own auth middleware intercepts the request, stores the full
// URI — including query params like ?deeplink=1 — as priorRoute in the session
// flash, then redirects to /login. After a successful login Xibo reads priorRoute
// and sends the user to the correct page. No client-side fetch-based auth check
// is needed, and avoids poisoning the priorRoute flash with "/" before we get there.
window.location.replace(destination);
})();
</script>
</body>

View File

@@ -1,91 +0,0 @@
{#
/**
* Copyright (C) 2026 OTS Signs
*
* About page for OTS Signs.
*/
#}
{% extends "non-authed.twig" %}
{% block title %}{{ "About"|trans }} | {% endblock %}
{% block style %}
<style type="text/css">
.about-container {
padding: 24px 30px 30px;
margin: 10px auto 20px;
background-color: #fff;
border: 1px solid #e5e5e5;
border-radius: 8px;
box-shadow: 0 1px 2px rgba(0,0,0,.05);
max-width: 720px;
}
.about-links {
display: flex;
flex-wrap: wrap;
gap: 16px;
margin-top: 12px;
}
.about-links a {
font-size: 14px;
}
.about-meta {
margin-top: 16px;
font-size: 14px;
color: #6c757d;
}
.about-disclaimer {
margin-top: 16px;
font-size: 14px;
}
</style>
{% endblock %}
{% block header %}{% endblock %}
{% block contentClass %}{% endblock %}
{% block content %}
<a class="btn btn-icon btn-info" href="{{ url_for("home") }}" title="{% trans "Home" %}"><i class="fa fa-home"></i></a>
<div class="about-container">
<h1>
{% trans "About" %}
<a href="https://ots-signs.com" target="_blank" rel="noopener noreferrer">OTS Signs</a>
</h1>
<p>
{% trans "An" %}
<a href="https://oribi-tech.com" target="_blank" rel="noopener noreferrer">Oribi Technology Services</a>
{% trans "product." %}
</p>
<p class="text-muted">{% trans "OTS Signs provides a compact, focused admin UI and proxy for Xibo CMS" %}</p>
{% set appVersion = version|default("dev") %}
{% set appEnvironment = appEnvironment|default(environment|default("local")) %}
{% set commitSha = revision|default("") %}
<div class="about-meta">
<div>{% trans "Version" %}: {{ appVersion }}</div>
<div>{% trans "Environment" %}: {{ appEnvironment }}</div>
{% if commitSha %}
<div>{% trans "Commit" %}: {{ commitSha|slice(0, 7) }}</div>
{% endif %}
</div>
<div class="about-links">
<a href="/privacy">{% trans "Privacy Policy" %}</a>
<a href="/terms">{% trans "Terms of Service" %}</a>
<a href="/open-source-licenses">{% trans "Open Source Licenses" %}</a>
</div>
<div class="about-disclaimer">
<strong>{% trans "Disclaimer:" %}</strong>
{% trans "OTS Signs is an independent product developed by Oribi Technology Services. It is not affiliated with or endorsed by the Xibo project or its maintainers. Xibo is a trademark of Xibo Digital Signage Ltd. Use of Xibo APIs is subject to their terms and conditions." %}
<div class="mt-2">
<a href="https://github.com/xibosignage/xibo" target="_blank" rel="noopener noreferrer">{% trans "Xibo CMS on GitHub" %}</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,51 +0,0 @@
{#
/**
* Copyright (C) 2026 OTS Signs
*
* About dialog content for OTS Signs.
*/
#}
{% extends "form-base.twig" %}
{% block formTitle %}{% trans "About" %}{% endblock %}
{% block formButtons %}
{% trans "Close" %}, XiboDialogClose()
{% endblock %}
{% block formHtml %}
<div class="about-container">
<h2>
{% trans "About" %}
<a href="https://ots-signs.com" target="_blank" rel="noopener noreferrer">OTS Signs</a>
</h2>
<p class="text-muted">{% trans "OTS Signs provides a compact, focused interface for your digital signage network" %}</p>
{% set appVersion = version|default("dev") %}
{% set appEnvironment = appEnvironment|default(environment|default("local")) %}
{% set commitSha = revision|default("") %}
<div class="about-meta">
<div>{% trans "Version" %}: {{ appVersion }}</div>
<div>{% trans "Environment" %}: {{ appEnvironment }}</div>
{% if commitSha %}
<div>{% trans "Commit" %}: {{ commitSha|slice(0, 7) }}</div>
{% endif %}
</div>
<div class="about-links">
<a href="/privacy">{% trans "Privacy Policy" %}</a>
<a href="/terms">{% trans "Terms of Service" %}</a>
<a href="/open-source-licenses">{% trans "Open Source Licenses" %}</a>
</div>
<div class="about-disclaimer">
<strong>{% trans "Disclaimer:" %}</strong>
{% trans "OTS Signs is an custom front end developed by Oribi Technology Services for the Xibo CMS. It is not affiliated with the Xibo project or its maintainers. Xibo is a trademark of Xibo Digital Signage Ltd. Use of Xibo is subject to their terms and conditions." %}
<div class="mt-2">
<a href="https://source.otshosting.app/OTSSigns/CMS-Server" target="_blank" rel="noopener noreferrer">{% trans "View the CMS server source on GitHub" %}</a>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,268 +0,0 @@
{#
/*
* OTS Signs Theme - Applications Page
* Based on Xibo CMS applications-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Applications"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Applications" %}</h1>
<p class="text-muted">{% trans "Manage API applications and connectors." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Applications" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add an Application" %}" href="{{ url_for("application.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="applications" class="table table-striped">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Owner" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<div class="widget content-card ots-displays-card mt-2">
<div class="widget-body ots-displays-body">
<div class="page-header ots-page-header">
<h1>{% trans "Connectors" %}</h1>
</div>
<div id="connectors" class="card-deck">
{% if theme.getThemeConfig("app_name") == "Xibo" %}
<div class="card p3 mt-2" style="min-width: 250px; max-width: 250px;">
<img class="card-img-top" style="max-height: 250px" src="{{ theme.rootUri() }}theme/default/img/connectors/canva_logo.png" alt="Canva">
<div class="card-body">
<h5 class="card-title">Canva</h5>
<p class="card-text">
Publish your designs from Canva to Xibo at the push of a button.
<br/>
<br/>
This connector is configured in Canva using the "Publish menu".
</p>
</div>
<div class="card-footer">
<a class="btn btn-primary" href="https://canva.com" target="_blank">Visit Canva</a>
</div>
</div>
{% endif %}
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
{% autoescape "js" %}
var copyToClipboardTrans = "{{ "Copy to Clipboard"|trans }}";
var couldNotCopyTrans = "{{ "Could not copy"|trans }}";
var copiedTrans = "{{ "Copied!"|trans }}";
{% endautoescape %}
var table;
$(document).ready(function() {
table = $('#applications').DataTable({
language: dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 0, "asc"]],
ajax: {
url: "{{ url_for('application.search') }}",
data: function (d) {
$.extend(d, $('#applications').closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "name", "render": dataTableSpacingPreformatted },
{ "data": "owner" },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#applications_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
// Connectors
loadConnectors();
});
function loadConnectors() {
var connectorTemplate = Handlebars.compile($('#template-connector-cards').html());
var $connectorContainer = $('#connectors');
$connectorContainer.find('.connector').remove();
$.ajax({
type: 'GET',
url: '{{ url_for("connector.search") }}?isVisible=1&showUninstalled=1',
cache: false,
dataType:"json",
success: function(xhr, textStatus, error) {
$.each(xhr.data, function(index, element) {
if (element.isHidden) {
return;
}
element.configureUrl = '{{ url_for("connector.edit.form", {id: ":id"}) }}'.replace(':id', element.connectorId);
element.proxyUrl = '{{ url_for("connector.edit.form.proxy", {id: ":id", method: ":method"}) }}'.replace(':id', element.connectorId);
element.thumbnail = element.thumbnail || 'theme/default/img/thumbs/placeholder.png';
if (!element.thumbnail.startsWith('http')) {
element.thumbnail = '{{ theme.rootUri() }}' + element.thumbnail;
}
element.enabledIcon = (element.isEnabled) ? 'fa-check' : 'fa-times';
element.classNameLast = element.className.substr(element.className.lastIndexOf('\\') + 1);
$connectorContainer.append(connectorTemplate(element));
});
$connectorContainer.trigger('connectors.loaded');
XiboInitialise('#connectors');
}
});
}
function connectorFormSubmit() {
XiboFormSubmit($('#connectorEditForm'), null, function() {
loadConnectors();
});
}
function copyFromSecretInput(dialog) {
$('#copy-button').tooltip();
$('#copy-button').bind('click', function() {
var input = $('#clientSecret');
input.focus();
input.select();
try {
var success = document.execCommand('copy');
if (success) {
$('#copy-button').trigger('copied', [copiedTrans]);
} else {
$('#copy-button').trigger('copied', [couldNotCopyTrans]);
}
} catch (err) {
$('#copy-button').trigger('copied', [couldNotCopyTrans]);
}
input.blur();
});
$('#copy-button').bind('copied', function(event, message) {
const $self = $(this);
$(this).tooltip('hide')
.attr('data-original-title', message)
.tooltip('show');
setTimeout(function() {
$self.tooltip('hide').attr('data-original-title', copyToClipboardTrans);
}, 1000);
});
onAuthCodeChanged(dialog);
$(dialog).find('#authCode').on('change', function() {
onAuthCodeChanged(dialog);
});
}
function onAuthCodeChanged(dialog) {
var authCode = $(dialog).find("#authCode").is(":checked");
var $authCodeTab = $(dialog).find(".tabForAuthCode");
if (authCode) {
$authCodeTab.removeClass("d-none");
} else {
$authCodeTab.addClass("d-none");
}
}
</script>
{% for js in connectorJavaScript %}
{% include js ~ ".twig" %}
{% endfor %}
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-connector-cards">
<div class="connector card p3 mt-2" style="min-width: 250px; max-width: 250px;"
data-proxy-url="{{proxyUrl}}"
data-connector-class-name="{{className}}"
data-connector-class-name-last="{{classNameLast}}"
data-connector-id="{{ connectorId }}">
{{#if thumbnail}}<img class="card-img-top" style="max-height: 250px" src="{{ thumbnail }}" alt="{{ title }}">{{/if}}
<div class="card-body">
<h5 class="card-title">{{ title }}</h5>
<p class="card-text">
{{ description }}
<br/>
<br/>
{{#if isInstalled }}
{% endverbatim %}{{ "Enabled"|trans }}{% verbatim %}: <span class="fa {{ enabledIcon }}"></span>
{{/if}}
{{#unless isInstalled }}
{% endverbatim %}{{ "Installed"|trans }}{% verbatim %}: <span class="fa fa-times"></span>
{{/unless}}
</p>
</div>
<div class="card-footer">
<button class="btn btn-primary XiboFormButton" href="{{ configureUrl }}">
{% endverbatim %}{{ "Configure"|trans }}{% verbatim %}
</button>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

View File

@@ -1,26 +0,0 @@
{#
Compact-aware notification drawer override
#}
{% if compact is defined and compact %}
<div class="dropdown nav-item item ots-notif-compact">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarNotificationDrawer">
<span class="ots-topbar-icon fa fa-bell" aria-hidden="true"></span>
</a>
<div class="dropdown-menu dropdown-menu-right ots-notif-menu" aria-labelledby="navbarNotificationDrawer">
<div class="dropdown-header">Notifications</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item">No new notifications</div>
</div>
</div>
{% else %}
<li class="dropdown nav-item item">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarNotificationDrawer">
<span class="ots-topbar-icon fa fa-bell" aria-hidden="true"></span>
</a>
<div class="dropdown-menu dropdown-menu-right ots-notif-menu" aria-labelledby="navbarNotificationDrawer">
<div class="dropdown-header">Notifications</div>
<div class="dropdown-divider"></div>
<div class="dropdown-item">No new notifications</div>
</div>
</li>
{% endif %}

View File

@@ -1,441 +0,0 @@
{#
OTS Signage Theme override
Based on Xibo CMS default authed-sidebar.twig (master branch)
Applied OTS sidebar styling
#}
<div id="sidebar-wrapper" class="ots-sidebar" role="navigation" aria-label="{% trans "Main navigation" %}">
<div class="sidebar-header">
<a class="brand-link" href="{{ url_for("home") }}">
<span class="brand-icon">
<img class="brand-logo" src="{{ theme.uri("img/xibologo.png") }}" alt="{% trans "Logo" %}">
</span>
<span class="brand-text">OTS Signs</span>
</a>
<button class="sidebar-expand-btn" type="button" aria-label="{% trans "Expand sidebar" %}">
<i class="fa fa-chevron-right" aria-hidden="true"></i>
</button>
<button class="sidebar-collapse-btn sidebar-collapse-btn-visible" type="button" aria-label="{% trans "Collapse sidebar" %}">
<i class="fa fa-chevron-left" aria-hidden="true"></i>
</button>
</div>
<div class="sidebar-content">
<ul class="sidebar ots-sidebar-nav">
<li class="sidebar-list">
<a href="{{ url_for("home") }}" data-tooltip="Dashboard">
<span class="ots-nav-icon fa fa-home" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Dashboard" %}</span>
</a>
</li>
{% set scheduleCount = currentUser.featureEnabledCount(["schedule.view", "daypart.view"]) %}
{% if scheduleCount > 0 %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" aria-controls="submenu-scheduling" data-group="scheduling">
<span class="ots-nav-icon fa fa-calendar" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Scheduling" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu" id="submenu-scheduling">
{% if currentUser.featureEnabled("daypart.view") %}
<li class="sidebar-list">
<a href="{{ url_for("daypart.view") }}">
<span class="ots-nav-icon fa fa-clock-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Dayparts" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
<li class="sidebar-list">
<a href="{{ url_for("schedule.view") }}">
<span class="ots-nav-icon fa fa-calendar-check-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Schedules" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["library.view", "playlist.view", "dataset.view", "menuBoard.view"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" aria-controls="submenu-media" data-group="media">
<span class="ots-nav-icon fa fa-picture-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Media" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu" id="submenu-media">
{% if currentUser.featureEnabled("library.view") %}
<li class="sidebar-list">
<a href="{{ url_for("library.view") }}">
<span class="ots-nav-icon fa fa-image" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Library" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("playlist.view") %}
<li class="sidebar-list">
<a href="{{ url_for("playlist.view") }}">
<span class="ots-nav-icon fa fa-list" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Playlists" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("dataset.view") %}
<li class="sidebar-list">
<a href="{{ url_for("dataset.view") }}">
<span class="ots-nav-icon fa fa-database" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "DataSets" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("menuBoard.view") %}
<li class="sidebar-list">
<a href="{{ url_for("menuBoard.view") }}">
<span class="ots-nav-icon fa fa-cutlery" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Menu Boards" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["campaign.view", "layout.view", "template.view", "resolution.view"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" aria-controls="submenu-design" data-group="design">
<span class="ots-nav-icon fa fa-paint-brush" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Design" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu" id="submenu-design">
{% if currentUser.featureEnabled("campaign.view") %}
<li class="sidebar-list">
<a href="{{ url_for("campaign.view") }}">
<span class="ots-nav-icon fa fa-bullhorn" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Campaigns" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
<li class="sidebar-list">
<a href="{{ url_for("layout.view") }}">
<span class="ots-nav-icon fa fa-columns" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Layouts" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("template.view") %}
<li class="sidebar-list">
<a href="{{ url_for("template.view") }}">
<span class="ots-nav-icon fa fa-clone" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Templates" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("resolution.view") %}
<li class="sidebar-list">
<a href="{{ url_for("resolution.view") }}">
<span class="ots-nav-icon fa fa-expand" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Resolutions" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["displays.view", "displaygroup.view", "displayprofile.view", "playersoftware.view", "command.view", "display.syncView"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" aria-controls="submenu-displays" data-group="displays">
<span class="ots-nav-icon fa fa-desktop" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Displays" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu" id="submenu-displays">
{% if currentUser.featureEnabled("displays.view") %}
<li class="sidebar-list">
<a href="{{ url_for("display.view") }}">
<span class="ots-nav-icon fa fa-desktop" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "All Displays" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
<li class="sidebar-list">
<a href="{{ url_for("displaygroup.view") }}">
<span class="ots-nav-icon fa fa-object-group" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Screen Groups" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("display.syncView") %}
<li class="sidebar-list">
<a href="{{ url_for("syncgroup.view") }}">
<span class="ots-nav-icon fa fa-link" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Sync Groups" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
<li class="sidebar-list">
<a href="{{ url_for("displayprofile.view") }}">
<span class="ots-nav-icon fa fa-cog" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Display Settings" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("playersoftware.view") %}
<li class="sidebar-list">
<a href="{{ url_for("playersoftware.view") }}">
<span class="ots-nav-icon fa fa-download" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Player Versions" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("command.view") %}
<li class="sidebar-list">
<a href="{{ url_for("command.view") }}">
<span class="ots-nav-icon fa fa-terminal" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Commands" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% if currentUser.featureEnabled("users.view") and (currentUser.isGroupAdmin() or currentUser.isSuperAdmin()) %}
{% set userMenuViewable = true %}
{% else %}
{% set userMenuViewable = false %}
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["usergroup.view", "module.view", "transition.view", "task.view", "tag.view", "font.view"]) %}
{% if countViewable > 0 or userMenuViewable %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" aria-controls="submenu-settings" data-group="settings">
<span class="ots-nav-icon fa fa-cog" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Settings" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu" id="submenu-settings">
{% if userMenuViewable %}
<li class="sidebar-list">
<a href="{{ url_for("user.view") }}">
<span class="ots-nav-icon fa fa-user" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Users" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("usergroup.view") %}
<li class="sidebar-list">
<a href="{{ url_for("group.view") }}">
<span class="ots-nav-icon fa fa-users" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "User Groups" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.isSuperAdmin() %}
<li class="sidebar-list">
<a href="{{ url_for("admin.view") }}">
<span class="ots-nav-icon fa fa-sliders" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Settings" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.isSuperAdmin() %}
<li class="sidebar-list">
<a href="{{ url_for("application.view") }}">
<span class="ots-nav-icon fa fa-puzzle-piece" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Applications" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("module.view") %}
<li class="sidebar-list">
<a href="{{ url_for("module.view") }}">
<span class="ots-nav-icon fa fa-cubes" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Modules" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("transition.view") %}
<li class="sidebar-list">
<a href="{{ url_for("transition.view") }}">
<span class="ots-nav-icon fa fa-random" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Transitions" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("task.view") %}
<li class="sidebar-list">
<a href="{{ url_for("task.view") }}">
<span class="ots-nav-icon fa fa-tasks" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Tasks" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("tag.view") %}
<li class="sidebar-list">
<a href="{{ url_for("tag.view") }}">
<span class="ots-nav-icon fa fa-tags" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Tags" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.isSuperAdmin() %}
<li class="sidebar-list">
<a href="{{ url_for("folders.view") }}">
<span class="ots-nav-icon fa fa-folder-open" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Folders" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("font.view") %}
<li class="sidebar-list">
<a href="{{ url_for("font.view") }}">
<span class="ots-nav-icon fa fa-font" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Fonts" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["report.view", "report.scheduling", "report.saving"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" data-group="reporting">
<span class="ots-nav-icon fa fa-bar-chart" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Reporting" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("report.view") %}
<li class="sidebar-list">
<a href="{{ url_for("report.view") }}">
<span class="ots-nav-icon fa fa-file-text-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "All Reports" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("report.scheduling") %}
<li class="sidebar-list">
<a href="{{ url_for("reportschedule.view") }}">
<span class="ots-nav-icon fa fa-calendar" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Report Schedules" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("report.saving") %}
<li class="sidebar-list">
<a href="{{ url_for("savedreport.view") }}">
<span class="ots-nav-icon fa fa-floppy-o" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Saved Reports" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["log.view", "sessions.view", "auditlog.view", "fault.view"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" data-group="advanced">
<span class="ots-nav-icon fa fa-shield" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Advanced" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("log.view") %}
<li class="sidebar-list">
<a href="{{ url_for("log.view") }}">
<span class="ots-nav-icon fa fa-list-alt" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Log" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("sessions.view") %}
<li class="sidebar-list">
<a href="{{ url_for("sessions.view") }}">
<span class="ots-nav-icon fa fa-user-secret" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Sessions" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("auditlog.view") %}
<li class="sidebar-list">
<a href="{{ url_for("auditlog.view") }}">
<span class="ots-nav-icon fa fa-clipboard" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Audit Trail" %}</span>
</a>
</li>
{% endif %}
{% if currentUser.featureEnabled("fault.view") %}
<li class="sidebar-list">
<a href="{{ url_for("fault.view") }}">
<span class="ots-nav-icon fa fa-exclamation-triangle" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Report Fault" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["developer.edit"]) %}
{% if countViewable > 0 %}
<li class="sidebar-group">
<a class="sidebar-group-toggle" href="#" aria-expanded="false" data-group="developer">
<span class="ots-nav-icon fa fa-code" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Developer" %}</span>
<span class="sidebar-group-caret fa fa-chevron-down" aria-hidden="true"></span>
</a>
<ul class="sidebar-submenu">
{% if currentUser.featureEnabled("developer.edit") %}
<li class="sidebar-list">
<a href="{{ url_for("developer.templates.view") }}">
<span class="ots-nav-icon fa fa-code" aria-hidden="true"></span>
<span class="ots-nav-text">{% trans "Module Templates" %}</span>
</a>
</li>
{% endif %}
</ul>
</li>
{% endif %}
</ul>
</div>
</div>

View File

@@ -1,6 +0,0 @@
{#
OTS Signage Theme override
Optional include rendered in authed.twig (top right navbar)
Minimal, low-risk addition for verification
#}
{# OTS topbar badge removed #}

View File

@@ -1,472 +0,0 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
<ul class="nav navbar-nav ots-topbar">
<li class="nav-item">
<a class="nav-link" href="{{ url_for("home") }}">
<span class="ots-topbar-icon fa fa-home" aria-hidden="true"></span>
{% trans "Dashboard" %}
</a>
</li>
{% set countViewable = currentUser.featureEnabledCount(["schedule.view", "daypart.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-calendar" aria-hidden="true"></span>
{% trans "Schedule" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("schedule.view") }}">
<span class="ots-topbar-icon fa fa-calendar" aria-hidden="true"></span>
{% trans "Schedule" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("daypart.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("daypart.view") }}">
<span class="ots-topbar-icon fa fa-clock" aria-hidden="true"></span>
{% trans "Dayparting" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["campaign.view", "layout.view", "template.view", "resolution.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-paint-brush" aria-hidden="true"></span>
{% trans "Design" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("campaign.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("campaign.view") }}">
<span class="ots-topbar-icon fa fa-bullhorn" aria-hidden="true"></span>
{% trans "Campaigns" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("layout.view") }}">
<span class="ots-topbar-icon fa fa-columns" aria-hidden="true"></span>
{% trans "Layouts" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("template.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("template.view") }}">
<span class="ots-topbar-icon fa fa-clone" aria-hidden="true"></span>
{% trans "Templates" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("resolution.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("resolution.view") }}">
<span class="ots-topbar-icon fa fa-expand" aria-hidden="true"></span>
{% trans "Resolutions" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["library.view", "playlist.view", "dataset.view", "menuBoard.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-folder-open" aria-hidden="true"></span>
{% trans "Library" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("playlist.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("playlist.view") }}">
<span class="ots-topbar-icon fa fa-list" aria-hidden="true"></span>
{% trans "Playlists" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("library.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("library.view") }}">
<span class="ots-topbar-icon fa fa-photo" aria-hidden="true"></span>
{% trans "Media" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("dataset.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("dataset.view") }}">
<span class="ots-topbar-icon fa fa-database" aria-hidden="true"></span>
{% trans "DataSets" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("menuBoard.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("menuBoard.view") }}">
<span class="ots-topbar-icon fa fa-th-large" aria-hidden="true"></span>
{% trans "Menu Boards" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["displays.view", "displaygroup.view", "displayprofile.view", "playersoftware.view", "command.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-desktop" aria-hidden="true"></span>
{% trans "Displays" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("displays.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("display.view") }}">
<span class="ots-topbar-icon fa fa-desktop" aria-hidden="true"></span>
{% trans "Displays" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("displaygroup.view") }}">
<span class="ots-topbar-icon fa fa-object-group" aria-hidden="true"></span>
{% trans "Display Groups" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("display.syncView") %}
<a class="{{ groupElementClass }}" href="{{ url_for("syncgroup.view") }}">
<span class="ots-topbar-icon fa fa-link" aria-hidden="true"></span>
{% trans "Sync Groups" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("displayprofile.view") }}">
<span class="ots-topbar-icon fa fa-sliders" aria-hidden="true"></span>
{% trans "Display Settings" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("playersoftware.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("playersoftware.view") }}">
<span class="ots-topbar-icon fa fa-download" aria-hidden="true"></span>
{% trans "Player Versions" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("command.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("command.view") }}">
<span class="ots-topbar-icon fa fa-terminal" aria-hidden="true"></span>
{% trans "Commands" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% if currentUser.featureEnabled("users.view") and (currentUser.isGroupAdmin() or currentUser.isSuperAdmin()) %}
{% set userMenuViewable = true %}
{% else %}
{% set userMenuViewable = false %}
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["usergroup.view", "module.view", "transition.view", "task.view"]) %}
{% set groupElementClass = (countViewable > 1 or (countViewable == 1 and userMenuViewable)) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 or userMenuViewable %}
{% if countViewable > 1 or (countViewable == 1 and userMenuViewable) %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-cog" aria-hidden="true"></span>
{% trans "Administration" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% endif %}
{% if userMenuViewable %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("user.view") }}">
<span class="ots-topbar-icon fa fa-users" aria-hidden="true"></span>
{% trans "Users" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("usergroup.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("group.view") }}">
<span class="ots-topbar-icon fa fa-users-cog" aria-hidden="true"></span>
{% trans "User Groups" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.isSuperAdmin() %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("admin.view") }}">
<span class="ots-topbar-icon fa fa-wrench" aria-hidden="true"></span>
{% trans "Settings" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.isSuperAdmin() %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("application.view") }}">
<span class="ots-topbar-icon fa fa-th" aria-hidden="true"></span>
{% trans "Applications" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("module.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("module.view") }}">
<span class="ots-topbar-icon fa fa-puzzle-piece" aria-hidden="true"></span>
{% trans "Modules" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("transition.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("transition.view") }}">
<span class="ots-topbar-icon fa fa-exchange" aria-hidden="true"></span>
{% trans "Transitions" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("task.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("task.view") }}">
<span class="ots-topbar-icon fa fa-tasks" aria-hidden="true"></span>
{% trans "Tasks" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("tag.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("tag.view") }}">
<span class="ots-topbar-icon fa fa-tags" aria-hidden="true"></span>
{% trans "Tags" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.isSuperAdmin() %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("folders.view") }}">
<span class="ots-topbar-icon fa fa-folder" aria-hidden="true"></span>
{% trans "Folders" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if currentUser.featureEnabled("font.view") %}
{% if countViewable == 0 %}
<li class="nav-item">
{% endif %}
<a class="{{ groupElementClass }}" href="{{ url_for("font.view") }}">
<span class="ots-topbar-icon fa fa-font" aria-hidden="true"></span>
{% trans "Fonts" %}
</a>
{% if countViewable == 0 %}
</li>
{% endif %}
{% endif %}
{% if countViewable > 1 or (countViewable == 1 and userMenuViewable) %}
</div>
{% endif %}
{% if countViewable > 1 or (countViewable == 1 and userMenuViewable) %}
</li>
{% endif %}
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["report.view", "report.scheduling", "report.saving"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-chart-bar" aria-hidden="true"></span>
{% trans "Reporting" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("report.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("report.view") }}">
<span class="ots-topbar-icon fa fa-file-alt" aria-hidden="true"></span>
{% trans "All Reports" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("report.scheduling") %}
<a class="{{ groupElementClass }}" href="{{ url_for("reportschedule.view") }}">
<span class="ots-topbar-icon fa fa-calendar-alt" aria-hidden="true"></span>
{% trans "Report Schedules" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("report.saving") %}
<a class="{{ groupElementClass }}" href="{{ url_for("savedreport.view") }}">
<span class="ots-topbar-icon fa fa-save" aria-hidden="true"></span>
{% trans "Saved Reports" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["log.view", "sessions.view", "auditlog.view", "fault.view"]) %}
{% set groupElementClass = (countViewable > 1) ? 'dropdown-item' : 'nav-link' %}
{% if countViewable > 0 %}
{% if countViewable > 1 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-shield-alt" aria-hidden="true"></span>
{% trans "Advanced" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% else %}
<li class="nav-item">
{% endif %}
{% if currentUser.featureEnabled("log.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("log.view") }}">
<span class="ots-topbar-icon fa fa-list-alt" aria-hidden="true"></span>
{% trans "Log" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("sessions.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("sessions.view") }}">
<span class="ots-topbar-icon fa fa-history" aria-hidden="true"></span>
{% trans "Sessions" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("auditlog.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("auditlog.view") }}">
<span class="ots-topbar-icon fa fa-clipboard-list" aria-hidden="true"></span>
{% trans "Audit Trail" %}
</a>
{% endif %}
{% if currentUser.featureEnabled("fault.view") %}
<a class="{{ groupElementClass }}" href="{{ url_for("fault.view") }}">
<span class="ots-topbar-icon fa fa-exclamation-triangle" aria-hidden="true"></span>
{% trans "Report Fault" %}
</a>
{% endif %}
{% if countViewable > 1 %}
</div>
{% endif %}
</li>
{% endif %}
{% set countViewable = currentUser.featureEnabledCount(["developer.edit"]) %}
{% if countViewable > 0 %}
<li class="nav-item dropdown">
<a href="#" class="nav-link dropdown-toggle" data-toggle="dropdown" role="button" aria-haspopup="true" aria-expanded="false">
<span class="ots-topbar-icon fa fa-code" aria-hidden="true"></span>
{% trans "Developer" %} <span class="caret" aria-hidden="true"></span>
</a>
<div class="dropdown-menu">
{% if currentUser.featureEnabled("developer.edit") %}
<a class="dropdown-item" href="{{ url_for("developer.templates.view") }}">
<span class="ots-topbar-icon fa fa-code-branch" aria-hidden="true"></span>
{% trans "Module Templates" %}
</a>
{% endif %}
</div>
</li>
{% endif %}
</ul>

View File

@@ -1,51 +0,0 @@
{#
OTS Signage Theme override
Based on Xibo CMS default authed-user-menu.twig (master branch)
Minimal change: add ots-user-menu class for easy verification
#}
{% if compact is defined and compact %}
<div class="dropdown nav-item item ots-user-menu-compact">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarUserMenu">
<img class="nav-avatar" src="{{ theme.uri("img/avatar.jpg") }}" />
</a>
<div class="dropdown-menu dropdown-menu-right ots-user-menu" aria-labelledby="navbarUserMenu">
{% else %}
<li class="dropdown nav-item item">
<a href="#" class="nav-link" data-toggle="dropdown" id="navbarUserMenu">
<img class="nav-avatar" src="{{ theme.uri("img/avatar.jpg") }}" />
</a>
<div class="dropdown-menu dropdown-menu-right ots-user-menu" aria-labelledby="navbarUserMenu">
{% endif %}
<h6 class="dropdown-header">{{ currentUser.userName }}<br/>
<div id="XiboClock">{{ clock }}</div>
</h6>
<div class="dropdown-divider"></div>
<a class="dropdown-item XiboFormButton" href="{{ url_for("user.preferences.form") }}" title="{% trans "Preferences" %}">{% trans "Preferences" %}</a>
{% if currentUser.featureEnabled("user.profile") %}
<a class="dropdown-item XiboFormButton" href="{{ url_for("user.edit.profile.form") }}" title="{% trans "Edit Profile" %}">{% trans "Edit Profile" %}</a>
{% endif %}
<a class="dropdown-item XiboFormButton" href="{{ url_for("user.applications") }}" title="{% trans "View my authenticated applications" %}">{% trans "My Applications" %}</a>
<div class="dropdown-divider"></div>
<a class="dropdown-item" id="ots-theme-toggle" href="#" title="Toggle light/dark mode">
<i class="fa fa-moon-o" id="ots-theme-icon" aria-hidden="true"></i>
<span id="ots-theme-label">Dark Mode</span>
</a>
<a class="dropdown-item" href="https://portal.oribi-tech.com" target="_blank" rel="noopener noreferrer" title="{% trans "Client Portal" %}">{% trans "Client Portal" %}</a>
<a class="dropdown-item XiboFormButton" href="{{ url_for("about") }}" title="{% trans "About the CMS" %}">{% trans "About" %}</a>
{% if not hideLogout %}
<div class="dropdown-divider"></div>
<a class="dropdown-item" title="{% trans "Logout" %}" href="{{ logoutUrl }}">{% trans "Logout" %}</a>
{% endif %}
</div>
{% if compact is defined and compact %}
</div>
{% else %}
</div>
</li>
{% endif %}

View File

@@ -23,47 +23,17 @@
{% extends "base.twig" %}
{% block headContent %}
{% if not currentUser.isSuperAdmin() and not currentUser.isGroupAdmin() %}
<script nonce="{{ cspNonce }}">
(function() {
var path = window.location.pathname;
if (path.indexOf('/layout/designer/') !== -1) return;
var cmsIdx = path.toLowerCase().indexOf('/cms');
var portalUrl = window.location.origin + (cmsIdx > 0 ? path.substring(0, cmsIdx) : '') + '/';
window.location.replace(portalUrl);
})();
</script>
{% endif %}
<script nonce="{{ cspNonce }}">
(function(){
try{
var stored = localStorage.getItem('ots-theme-mode');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var mode = stored || (prefersLight ? 'light' : 'dark');
if(mode === 'light') document.documentElement.classList.add('ots-light-mode');
else document.documentElement.classList.remove('ots-light-mode');
}catch(e){}
})();
(function(){
// Apply collapsed sidebar state early to prevent header flashing
try {
var collapsed = localStorage.getItem('otsTheme:sidebarCollapsed');
if (collapsed === 'true') {
document.documentElement.classList.add('ots-sidebar-collapsed');
if (document.body) document.body.classList.add('ots-sidebar-collapsed');
}
} catch (e) {}
})();
</script>
<style nonce="{{ cspNonce }}">
/* Let the CSS variable theming (light/dark) control page background */
html,body{background-color:var(--color-background,#0f172a)!important;color:var(--color-text-primary,#ffffff)!important}
/* Hide the old topbar strip entirely — actions are now in .ots-page-actions */
.row.header.header-side,
.ots-topbar-strip { display: none !important; height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; }
</style>
{% if not currentUser.isSuperAdmin() and not currentUser.isGroupAdmin() %}
<script nonce="{{ cspNonce }}">
(function() {
var path = window.location.pathname;
if (path.indexOf('/layout/designer/') !== -1) return;
var cmsIdx = path.toLowerCase().indexOf('/cms');
var portalUrl = window.location.origin + (cmsIdx > 0 ? path.substring(0, cmsIdx) : '') + '/';
window.location.replace(portalUrl);
})();
</script>
{% endif %}
{% endblock %}
{% block content %}
@@ -80,10 +50,6 @@
<nav class="navbar navbar-default navbar-expand-lg">
<a class="navbar-brand xibo-logo-container" href="#">
<img class="xibo-logo" src="{{ theme.uri("img/xibologo.png") }}">
<span class="xibo-logo-text">
<span class="brand-line brand-line-top">OTS</span>
<span class="brand-line brand-line-bottom">Signs</span>
</span>
</a>
<!-- Brand and toggle get grouped for better mobile display -->
@@ -111,23 +77,33 @@
{% endif %}
{% endif %}
<div id="content-wrapper" class="{% if hideNavigation == "1" %}no-nav{% endif %}{% if horizontalNav %} ots-horizontal-nav{% endif %}">
{# Floating top-right actions: notification bell + user menu #}
{# Hidden when horizontal nav is active — the navbar already has these controls #}
{% if not forceHide and not horizontalNav %}
<div class="ots-page-actions"{% if hideNavigation == "1" %} style="display:none!important"{% endif %}>
{% include "authed-theme-topbar.twig" ignore missing %}
{% if currentUser.featureEnabled("drawer") %}
<div class="ots-topbar-action">
{% include "authed-notification-drawer.twig" with { 'compact': true } %}
</div>
{% endif %}
<div class="ots-topbar-action">
{% include "authed-user-menu.twig" with { 'compact': true } %}
</div>
</div>
{% endif %}
<div id="content-wrapper">
<div class="page-content">
{% if not horizontalNav or hideNavigation == "1" or forceHide %}
<div class="row header header-side">
<div class="col-sm-12">
<div class="meta pull-left xibo-logo-container">
<div class="page"><img class="xibo-logo" src="{{ theme.uri("img/xibologo.png") }}"></div>
</div>
{% if not forceHide %}
{% if not hideNavigation == "1" %}
<button type="button" class="pull-right navbar-toggler navbar-toggler-side" data-toggle="collapse" data-target="#navbar-collapse-1" aria-controls="navbarNav" aria-expanded="false">
<span class="fa fa-bars"></span>
</button>
{% endif %}
<div class="user pull-right">
{% include "authed-user-menu.twig" %}
</div>
{% if currentUser.featureEnabled("drawer") %}
<div class="user user-notif pull-right">
{% include "authed-notification-drawer.twig" %}
</div>
{% endif %}
{% include "authed-theme-topbar.twig" ignore missing %}
{% endif %}
</div>
</div>
{% endif %}
<div class="row">
<div class="col-sm-12">
{% block actionMenu %}{% endblock %}
@@ -169,4 +145,4 @@
{% block javaScriptTemplates %}
{# File upload templates and scripts #}
{% include "include-file-upload.twig" %}
{% endblock %}
{% endblock %}

View File

@@ -1,183 +0,0 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Campaigns"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Campaigns" %}</h1>
<p class="text-muted">{% trans "Manage your campaigns and ad campaigns." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="campaignView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Campaigns" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set title %}{% trans "Layouts" %}{% endset %}
{% set values = [{id: 0, value: ""}, {id: 2, value: "Yes"}, {id: 1, value: "No"}] %}
{{ inline.dropdown("hasLayouts", "single", title, 0, values, "id", "value") }}
{{ inline.hidden("folderId") }}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title, layoutId) }}
{% if currentUser.featureEnabled('ad.campaign') %}
{% set title %}{% trans "Type" %}{% endset %}
{% set options = [
{ id: null, name: "" },
{ id: "list", name: "Layout list"|trans },
{ id: "ad", name: "Ad Campaign"|trans }
] %}
{{ inline.dropdown("type", "single", title, "both", options, "id", "name", helpText) }}
{% endif %}
{% set title %}{% trans "Cycle Based Playback" %}{% endset %}
{% set enabled %}{% trans "Enabled" %}{% endset %}
{% set disabled %}{% trans "Disabled" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 0, option: disabled},
{ optionid: 1, option: enabled}
] %}
{{ inline.dropdown("cyclePlaybackEnabled", "single", title, "", options, "optionid", "option") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("campaign.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Campaign" %}" href="{{ url_for("campaign.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="campaigns" class="table table-striped" data-content-type="campaign" data-content-id-name="campaignId" data-state-preference-name="campaignGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "Name" %}</th>
{% if currentUser.featureEnabled('ad.campaign') %}
<th>{% trans "Type" %}</th>
<th>{% trans "Start Date" %}</th>
<th>{% trans "End Date" %}</th>
{% endif %}
<th>{% trans "# Layouts" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Duration" %}</th>
<th>{% trans "Cycle based Playback" %}</th>
<th>{% trans "Play Count" %}</th>
{% if currentUser.featureEnabled('ad.campaign') %}
<th>{% trans "Target Type" %}</th>
<th>{% trans "Target" %}</th>
<th>{% trans "Plays" %}</th>
<th>{% trans "Spend" %}</th>
<th>{% trans "Impressions" %}</th>
{% endif %}
<th>{% trans "Ref 1" %}</th>
<th>{% trans "Ref 2" %}</th>
<th>{% trans "Ref 3" %}</th>
<th>{% trans "Ref 4" %}</th>
<th>{% trans "Ref 5" %}</th>
<th>{% trans "Created At" %}</th>
<th>{% trans "Modified At" %}</th>
<th>{% trans "Modified By" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables and translations #}
<script type="text/javascript" nonce="{{ cspNonce }}" defer>
{# JS variables #}
var campaignSearchURL = "{{ url_for('campaign.search') }}";
var layoutSearchURL = "{{ url_for('layout.search') }}";
var folderViewEnabled = "{{ currentUser.featureEnabled('folder.view') }}";
var adCampaignEnabled = "{{ currentUser.featureEnabled('ad.campaign') }}";
var taggingEnabled = "{{ currentUser.featureEnabled('tag.tagging') }}";
{# Custom translations #}
var campaignPageTrans = {
list: "{% trans "List" %}",
ad: "{% trans "Ad" %}",
plays: "{% trans "Plays" %}",
budget: "{% trans "Budget" %}",
impressions: "{% trans "Impressions" %}",
};
</script>
{# Add page source code bundle #}
<script src="{{ theme.rootUri() }}dist/pages/campaign-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{% endblock %}

View File

@@ -1,161 +0,0 @@
{#
/**
* Copyright (C) 2020-2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Commands"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Commands" %}</h1>
<p class="text-muted">{% trans "Create and manage commands for Displays." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Commands" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('command', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.inputNameGrid('code', title, null, 'useRegexForCode', 'logicalOperatorCode') }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.featureEnabled("command.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add Command" %}" href="{{ url_for("command.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="commands" class="table table-striped" data-state-preference-name="commandGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Available On" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Sharing" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#commands").DataTable({ "language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("command.search") }}",
"data": function(d) {
$.extend(d, $("#commands").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "command", "render": dataTableSpacingPreformatted , responsivePriority: 2},
{ "data": "code" , responsivePriority: 2},
{
"data": "availableOn",
responsivePriority: 3,
"render": function(data, type) {
if (type !== "display")
return data;
var returnData = '';
if (typeof data !== undefined && data != null) {
var arrayOfTags = data.split(',');
returnData += '<div class="permissionsDiv">';
for (var i = 0; i < arrayOfTags.length; i++) {
var name = arrayOfTags[i];
if (name !== '') {
returnData += '<li class="badge ' + ((name === 'lg') ? '' : 'capitalize') + '">' + name.replace("lg", "webOS").replace("sssp", "Tizen") + '</span></li>'
}
}
returnData += '</div>';
}
return returnData;
}
},
{ "data": "description", responsivePriority: 3 },
{
"data": "groupsWithPermissions",
responsivePriority: 3,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#commands_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
</script>
{% endblock %}

View File

@@ -1,537 +0,0 @@
{#
/**
* OTS Signage Theme - Icon Dashboard Override
*
* Custom stylized icon dashboard that uses card-based buttons
* matching the OTS dashboard design system.
*
* Based on Xibo CMS dashboard-icon-page.twig
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block pageContent %}
{% include "theme-dashboard-message.twig" ignore missing %}
<div class="dashboard-page">
<div class="page-header">
<h1>{% trans "Dashboard" %}</h1>
<p class="text-muted">{% trans "Quick access to all areas of your signage network" %}</p>
</div>
{# ── Status Bar ──────────────────────────────────────────── #}
<div class="ots-stat-bar">
{% if currentUser.featureEnabled("displays.view") %}
<a class="ots-stat-tile" href="{{ url_for("display.view") }}">
<div class="ots-stat-tile-icon ots-stat-tile-icon--green">
<i class="fa fa-desktop"></i>
</div>
<div class="ots-stat-tile-content">
<span class="ots-stat-tile-number" id="ots-stat-displays">—</span>
<span class="ots-stat-tile-label">{% trans "Displays" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
<a class="ots-stat-tile" href="{{ url_for("layout.view") }}">
<div class="ots-stat-tile-icon ots-stat-tile-icon--blue">
<i class="fa fa-columns"></i>
</div>
<div class="ots-stat-tile-content">
<span class="ots-stat-tile-number" id="ots-stat-layouts">—</span>
<span class="ots-stat-tile-label">{% trans "Layouts" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("library.view") %}
<a class="ots-stat-tile" href="{{ url_for("library.view") }}">
<div class="ots-stat-tile-icon ots-stat-tile-icon--orange">
<i class="fa fa-image"></i>
</div>
<div class="ots-stat-tile-content">
<span class="ots-stat-tile-number" id="ots-stat-media">—</span>
<span class="ots-stat-tile-label">{% trans "Media Files" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
<a class="ots-stat-tile" href="{{ url_for("schedule.view") }}">
<div class="ots-stat-tile-icon ots-stat-tile-icon--purple">
<i class="fa fa-calendar-check-o"></i>
</div>
<div class="ots-stat-tile-content">
<span class="ots-stat-tile-number" id="ots-stat-schedules">—</span>
<span class="ots-stat-tile-label">{% trans "Scheduled Events" %}</span>
</div>
</a>
{% endif %}
</div>
{# ── Scheduling ────────────────────────────────────────────── #}
{% set scheduleCount = currentUser.featureEnabledCount(["schedule.view", "daypart.view"]) %}
{% if scheduleCount > 0 %}
<div class="icon-dash-section">
<details open>
<summary class="section-title"><i class="fa fa-calendar"></i> {% trans "Scheduling" %}</summary>
<div class="icon-dash-grid">
{% if currentUser.featureEnabled("schedule.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("schedule.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--blue">
<i class="fa fa-calendar-check-o"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Schedule" %}</span>
<span class="icon-dash-card-desc">{% trans "Manage scheduled events" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("daypart.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("daypart.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--indigo">
<i class="fa fa-clock-o"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Dayparting" %}</span>
<span class="icon-dash-card-desc">{% trans "Define time segments" %}</span>
</div>
</a>
{% endif %}
</div>
</details>
</div>
{% endif %}
{# ── Design ────────────────────────────────────────────────── #}
{% set countViewable = currentUser.featureEnabledCount(["campaign.view", "layout.view", "template.view", "resolution.view"]) %}
{% if countViewable > 0 %}
<div class="icon-dash-section">
<details open>
<summary class="section-title"><i class="fa fa-paint-brush"></i> {% trans "Design" %}</summary>
<div class="icon-dash-grid">
{% if currentUser.featureEnabled("campaign.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("campaign.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--green">
<i class="fa fa-bullhorn"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Campaigns" %}</span>
<span class="icon-dash-card-desc">{% trans "Organise layout playlists" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("layout.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--blue">
<i class="fa fa-columns"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Layouts" %}</span>
<span class="icon-dash-card-desc">{% trans "Design screen content" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("template.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("template.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--purple">
<i class="fa fa-clone"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Templates" %}</span>
<span class="icon-dash-card-desc">{% trans "Reusable layout patterns" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("resolution.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("resolution.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--teal">
<i class="fa fa-expand"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Resolutions" %}</span>
<span class="icon-dash-card-desc">{% trans "Screen size presets" %}</span>
</div>
</a>
{% endif %}
</div>
</details>
</div>
{% endif %}
{# ── Library ───────────────────────────────────────────────── #}
{% set countViewable = currentUser.featureEnabledCount(["library.view", "playlist.view", "dataset.view", "menuBoard.view"]) %}
{% if countViewable > 0 %}
<div class="icon-dash-section">
<details open>
<summary class="section-title"><i class="fa fa-picture-o"></i> {% trans "Library" %}</summary>
<div class="icon-dash-grid">
{% if currentUser.featureEnabled("library.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("library.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--orange">
<i class="fa fa-image"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Library" %}</span>
<span class="icon-dash-card-desc">{% trans "Upload and manage media" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("playlist.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("playlist.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--blue">
<i class="fa fa-list"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Playlists" %}</span>
<span class="icon-dash-card-desc">{% trans "Content play sequences" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("dataset.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("dataset.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--indigo">
<i class="fa fa-database"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "DataSets" %}</span>
<span class="icon-dash-card-desc">{% trans "Structured data sources" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("menuBoard.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("menuBoard.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--red">
<i class="fa fa-cutlery"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Menu Boards" %}</span>
<span class="icon-dash-card-desc">{% trans "Digital menu layouts" %}</span>
</div>
</a>
{% endif %}
</div>
</details>
</div>
{% endif %}
{# ── Displays ──────────────────────────────────────────────── #}
{% set countViewable = currentUser.featureEnabledCount(["displays.view", "displaygroup.view", "displayprofile.view"]) %}
{% if countViewable > 0 %}
<div class="icon-dash-section">
<details open>
<summary class="section-title"><i class="fa fa-desktop"></i> {% trans "Displays" %}</summary>
<div class="icon-dash-grid">
{% if currentUser.featureEnabled("displays.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("display.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--green">
<i class="fa fa-desktop"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Displays" %}</span>
<span class="icon-dash-card-desc">{% trans "Monitor your screens" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("displaygroup.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--blue">
<i class="fa fa-object-group"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Display Groups" %}</span>
<span class="icon-dash-card-desc">{% trans "Group displays together" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("displayprofile.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--purple">
<i class="fa fa-cog"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Display Settings" %}</span>
<span class="icon-dash-card-desc">{% trans "Configure display profiles" %}</span>
</div>
</a>
{% endif %}
</div>
</details>
</div>
{% endif %}
{# ── Administration ────────────────────────────────────────── #}
{% set showAdmin = false %}
{% if currentUser.featureEnabled("users.view") and (currentUser.isGroupAdmin() or currentUser.isSuperAdmin()) %}
{% set showAdmin = true %}
{% endif %}
{% if currentUser.isSuperUser() %}
{% set showAdmin = true %}
{% endif %}
{% if showAdmin %}
<div class="icon-dash-section">
<details open>
<summary class="section-title"><i class="fa fa-cogs"></i> {% trans "Administration" %}</summary>
<div class="icon-dash-grid">
{% if currentUser.featureEnabled("users.view") and (currentUser.isGroupAdmin() or currentUser.isSuperAdmin()) %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("user.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--indigo">
<i class="fa fa-users"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Users" %}</span>
<span class="icon-dash-card-desc">{% trans "Manage user accounts" %}</span>
</div>
</a>
{% endif %}
{% if currentUser.isSuperUser() %}
<a class="icon-dash-card dashboard-card" href="{{ url_for("admin.view") }}">
<div class="icon-dash-card-icon icon-dash-card-icon--orange">
<i class="fa fa-cogs"></i>
</div>
<div class="icon-dash-card-body">
<span class="icon-dash-card-title">{% trans "Settings" %}</span>
<span class="icon-dash-card-desc">{% trans "System configuration" %}</span>
</div>
</a>
{% endif %}
</div>
</details>
</div>
{% endif %}
</div>
<style nonce="{{ cspNonce }}">
/* ===================================================================
ICON DASHBOARD Card Button Styles
Matches the OTS dashboard-card design system
=================================================================== */
/* Section spacing */
.icon-dash-section {
margin-top: 32px;
}
.icon-dash-section:first-of-type {
margin-top: 24px;
}
/* Grid layout responsive card grid */
.icon-dash-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
gap: 18px;
margin-top: 14px;
}
/* Individual card inherits .dashboard-card base from override.css */
.icon-dash-card {
display: flex;
flex-direction: row;
align-items: center;
gap: 18px;
padding: 22px 24px;
text-decoration: none !important;
color: var(--color-text-primary) !important;
cursor: pointer;
position: relative;
overflow: hidden;
/* Override rigid dashboard-card flex-direction:column if set */
flex-direction: row !important;
}
/* Icon container */
.icon-dash-card-icon {
flex-shrink: 0;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 8px;
font-size: 22px;
}
/* Icon colour variants — flat tinted backgrounds, no gradient formula */
.icon-dash-card-icon--blue {
background: rgba(50, 110, 220, 0.14);
color: #5c9bff;
}
.icon-dash-card-icon--green {
background: rgba(22, 175, 120, 0.14);
color: #2eb88a;
}
.icon-dash-card-icon--orange {
background: rgba(232, 120, 0, 0.14);
color: #e87800;
}
.icon-dash-card-icon--red {
background: rgba(220, 70, 70, 0.14);
color: #e26060;
}
.icon-dash-card-icon--purple {
background: rgba(145, 80, 220, 0.14);
color: #b37dd9;
}
.icon-dash-card-icon--indigo {
background: rgba(95, 100, 210, 0.14);
color: #8d91e8;
}
.icon-dash-card-icon--teal {
background: rgba(20, 175, 158, 0.14);
color: #24bfae;
}
/* Text area */
.icon-dash-card-body {
display: flex;
flex-direction: column;
min-width: 0;
/* Reset inherited dashboard-card body padding */
padding: 0 !important;
background: transparent !important;
}
.icon-dash-card-title {
font-size: 15px;
font-weight: 600;
color: var(--color-text-primary);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hover effects */
.icon-dash-card:hover {
border-color: rgba(232, 120, 0, 0.4) !important;
transform: translateY(-2px);
box-shadow: 0 8px 24px rgba(8, 15, 30, 0.3) !important;
}
.icon-dash-card:active {
transform: translateY(0px);
box-shadow: 0 3px 8px rgba(8, 15, 30, 0.2) !important;
}
/* Section title with icon */
.icon-dash-section .section-title i {
margin-right: 8px;
opacity: 0.65;
}
/* ── Stat bar link reset ── */
.ots-stat-tile {
text-decoration: none !important;
color: inherit !important;
}
/* ── Light mode overrides ─────────────────────────────────────── */
body.ots-light-mode .icon-dash-card {
background: #ffffff !important;
border-color: rgba(148, 163, 184, 0.25) !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05) !important;
}
body.ots-light-mode .icon-dash-card:hover {
background: #ffffff !important;
border-color: rgba(232, 120, 0, 0.4) !important;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.09) !important;
}
/* ── Responsive adjustments ───────────────────────────────────── */
@media (max-width: 768px) {
.icon-dash-grid {
grid-template-columns: 1fr 1fr;
gap: 12px;
}
.icon-dash-card {
padding: 16px 18px;
gap: 14px;
}
.icon-dash-card-icon {
width: 44px;
height: 44px;
font-size: 18px;
border-radius: 6px;
}
.icon-dash-card-title {
font-size: 13px;
}
.icon-dash-card-desc {
display: none;
}
}
@media (max-width: 480px) {
.icon-dash-grid {
grid-template-columns: 1fr;
}
}
</style>
{% endblock %}
{% block javaScript %}
{# ── Dashboard stat tile counters ── #}
<script type="text/javascript" nonce="{{ cspNonce }}">
(function() {
'use strict';
var $ = window.jQuery;
if (!$) return;
function fetchCount(url, elId, key) {
$.ajax({
url: url,
type: 'GET',
dataType: 'json',
data: { start: 0, length: 1 },
success: function(resp) {
var count = 0;
if (resp && typeof resp.recordsTotal !== 'undefined') {
count = resp.recordsTotal;
} else if (resp && Array.isArray(resp.data)) {
count = resp.data.length;
} else if (resp && typeof resp.total !== 'undefined') {
count = resp.total;
}
var el = document.getElementById(elId);
if (el) el.textContent = count.toLocaleString();
},
error: function() {
var el = document.getElementById(elId);
if (el) el.textContent = '—';
}
});
}
$(function() {
{% if currentUser.featureEnabled("displays.view") %}
fetchCount('{{ url_for("display.search") }}', 'ots-stat-displays');
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
fetchCount('{{ url_for("layout.search") }}', 'ots-stat-layouts');
{% endif %}
{% if currentUser.featureEnabled("library.view") %}
fetchCount('{{ url_for("library.search") }}', 'ots-stat-media');
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
fetchCount('{{ url_for("schedule.search") }}', 'ots-stat-schedules');
{% endif %}
});
})();
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,596 +0,0 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% import "forms.twig" as forms %}
{% block title %}{{ "DataSets"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "DataSets" %}</h1>
<p class="text-muted">{% trans "Manage structured data sources." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="dataSetView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter DataSets" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline" onsubmit="return false">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('dataSet', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{% set helpText %}{% trans "Show items which match the provided code" %}{% endset %}
{{ inline.input("code", title, "", helpText) }}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("dataset.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new DataSet" %}" href="{{ url_for("dataSet.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="datasets" class="table table-striped" data-state-preference-name="dataSetGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Remote?" %}</th>
<th>{% trans "Real time?" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Last Sync" %}</th>
<th>{% trans "Data Last Modified" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#datasets").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 0, "asc"]],
ajax: {
"url": "{{ url_for("dataSet.search") }}",
"data": function(d) {
$.extend(d, $("#datasets").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "dataSetId", responsivePriority: 2 },
{ "data": "dataSet", "render": dataTableSpacingPreformatted, responsivePriority: 2 },
{ "data": "description", responsivePriority: 4 },
{ "data": "code", responsivePriority: 3 },
{
"data": "isRemote",
responsivePriority: 3,
"render": dataTableTickCrossColumn
},
{
data: 'isRealTime',
responsivePriority: 3,
render: dataTableTickCrossColumn,
},
{ "data": "owner", responsivePriority: 3 },
{
"data": "groupsWithPermissions",
responsivePriority: 3,
"render": dataTableCreatePermissions
},
{
"data": "lastSync",
responsivePriority: 4,
"render": dataTableDateFromUnix
},
{
"data": "lastDataEdit",
responsivePriority: 4,
"render": dataTableDateFromUnix
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', function(e, settings) {
dataTableDraw(e, settings);
// Upload form
$(".dataSetImportForm").click(function(e) {
e.preventDefault();
var template = Handlebars.compile($("#template-dataset-upload").html());
var data = table.row($(this).closest("tr")).data();
var columns = [];
var i = 1;
$.each(data.columns, function (index, element) {
if (element.dataSetColumnTypeId === 1) {
element.index = i;
columns.push(element);
i++;
}
});
// Handle bars and open a dialog
bootbox.dialog({
message: template({
trans: {
addFiles: "{% trans "Add CSV Files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}",
processing: "{% trans "Processing..." %}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ libraryUpload.validExt }}",
utf8Message: "{% trans "If the CSV file contains non-ASCII characters please ensure the file is UTF-8 encoded" %}"
},
columns: columns
}),
title: "{% trans "CSV Import" %}",
size: 'large',
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function() {
table.ajax.reload();
XiboDialogClose();
}
}
}
}).on('shown.bs.modal', function() {
// Configure the upload form
var url = "{{ url_for("dataSet.import", {id: ':id'}) }}".replace(":id", data.dataSetId);
var form = $(this).find("form");
var refreshSessionInterval;
// Initialize the jQuery File Upload widget:
form.fileupload({
url: url,
disableImageResize: true
});
// Upload server status check for browsers with CORS support:
if ($.support.cors) {
$.ajax({
url: url,
type: 'HEAD'
}).fail(function () {
$('<span class="alert alert-error"/>')
.text('Upload server currently unavailable - ' + new Date())
.appendTo(form);
});
}
// Enable iframe cross-domain access via redirect option:
form.fileupload(
'option',
'redirect',
window.location.href.replace(
/\/[^\/]*$/,
'/cors/result.html?%s'
)
);
form.bind('fileuploadsubmit', function (e, data) {
var inputs = data.context.find(':input');
if (inputs.filter('[required][value=""]').first().focus().length) {
return false;
}
data.formData = inputs.serializeArray().concat(form.serializeArray());
inputs.filter("input").prop("disabled", true);
}).bind('fileuploadstart', function (e, data) {
// Show progress data
form.find('.fileupload-progress .progress-extended').show();
form.find('.fileupload-progress .progress-end').hide();
if (form.fileupload("active") <= 0)
refreshSessionInterval = setInterval("XiboPing('" + pingUrl + "?refreshSession=true')", 1000 * 60 * 3);
return true;
}).bind('fileuploaddone', function (e, data) {
if (refreshSessionInterval != null && form.fileupload("active") <= 0)
clearInterval(refreshSessionInterval);
}).bind('fileuploadprogressall', function (e, data) {
// Hide progress data and show processing
if(data.total > 0 && data.loaded == data.total) {
form.find('.fileupload-progress .progress-extended').hide();
form.find('.fileupload-progress .progress-end').show();
}
}).bind('fileuploadadded fileuploadcompleted fileuploadfinished', function (e, data) {
// Get uploaded and downloaded files and toggle Done button
var filesToUploadCount = form.find('tr.template-upload').length;
var $button = form.parents('.modal:first').find('button.btn-bb-main');
if(filesToUploadCount == 0) {
$button.removeAttr('disabled');
} else {
$button.attr('disabled', 'disabled');
}
});
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#datasets_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
function dataSetFormOpen(dialog) {
// Bind the remote dataset test button
$(dialog).find("#dataSetRemoteTestButton").on('click', function() {
var $form = $(dialog).find("form");
XiboRemoteRequest("{{ url_for("dataSet.test.remote") }}", $form.serializeObject(), function(response) {
if (!response.success || !$.trim(response.data.entries)) {
response.data = response.message;
}
$("#datasetRemoteTestRequestResult").html('<pre style="height: 300px; overflow: scroll">' + JSON.stringify(response.data, null, 3) + '</pre>');
});
});
// Set up some dependencies between the isRemote checkbox and the tabs related to remote datasets
onRemoteFieldChanged(dialog);
// show data source dropdown if real time is checked
onIsRealTimeFieldChanged(dialog);
$(dialog).find("#isRemote").on('change', function() {
onRemoteFieldChanged(dialog);
});
$(dialog).find("#isRealTime").on('change', function() {
onIsRealTimeFieldChanged(dialog);
});
// Auth field
onAuthenticationFieldChanged(dialog);
$(dialog).find("#authentication").on('change', function() {
onAuthenticationFieldChanged(dialog);
});
// remote DataSet source
onSourceFieldChanged(dialog);
$(dialog).find('#sourceId').on('change', function() {
onSourceFieldChanged(dialog);
});
// Validate form manually because
// uri field depends on isRemote being checked
if (forms != undefined) {
const $form = $(dialog).find('form');
forms.validateForm(
$form, // form
$form.parent(), // container
{
submitHandler: XiboFormSubmit,
rules: {
uri: {
required: function(element) {
return $form.find('#isRemote').is(':checked')
},
},
},
},
);
}
}
function onIsRealTimeFieldChanged(dialog) {
var isRealTime = $(dialog).find("#isRealTime").is(":checked");
var dataSourceField = $(dialog).find("#dataSourceField");
var dataConnectorSource = $(dialog).find("#dataConnectorSource");
if (isRealTime) {
// show and enable data connector source
dataSourceField.removeClass("d-none");
dataConnectorSource.prop('disabled', false)
} else {
// hide and disable data connector source
dataSourceField.addClass("d-none");
dataConnectorSource.prop('disabled', true)
}
}
function onRemoteFieldChanged(dialog) {
var isRemote = $(dialog).find("#isRemote").is(":checked");
var $remoteTabs = $(dialog).find(".tabForRemoteDataSet");
if (isRemote) {
$remoteTabs.removeClass("d-none");
} else {
$remoteTabs.addClass("d-none");
}
}
function onAuthenticationFieldChanged(dialog) {
var authentication = $(dialog).find("#authentication").val();
var $authFieldUserName = $(dialog).find(".auth-field-username");
var $authFieldPassword = $(dialog).find(".auth-field-password");
if (authentication === "none") {
$authFieldUserName.addClass("d-none");
$authFieldPassword.addClass("d-none");
} else if (authentication === "bearer") {
$authFieldUserName.addClass("d-none");
$authFieldPassword.removeClass("d-none");
} else {
$authFieldUserName.removeClass("d-none");
$authFieldPassword.removeClass("d-none");
}
}
function onSourceFieldChanged(dialog) {
var sourceId = $(dialog).find('#sourceId').val();
var $jsonSource = $(dialog).find(".json-source-field");
var $csvSource = $(dialog).find(".csv-source-field");
if (sourceId == 1) {
$jsonSource.removeClass('d-none');
$csvSource.addClass('d-none');
} else {
$jsonSource.addClass('d-none');
$csvSource.removeClass('d-none');
}
}
function deleteMultiSelectFormOpen(dialog) {
{% set message = 'Delete any associated data?' %}
var $input = $('<input type=checkbox id="deleteData" name="deleteData"> {{ message|trans|e }} </input>');
$input.on('change', function() {
dialog.data().commitData = {deleteData: $(this).val()};
});
$(dialog).find('.modal-body').append($input);
}
</script>
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-dataset-upload">
<form class="form-horizontal" method="post" enctype="multipart/form-data" data-max-file-size="{{ upload.maxSize }}" data-accept-file-types="/(\.|\/)csv/i">
<div class="row fileupload-buttonbar">
<div class="card p-3 mb-3 bg-light">
{{ upload.maxSizeMessage }} <br>
{{ upload.utf8Message }}
</div>
<div class="col-md-7">
<!-- The fileinput-button span is used to style the file input field as button -->
<span class="btn btn-success fileinput-button">
<i class="fa fa-plus"></i>
<span>{{ trans.addFiles }}</span>
<input type="file" name="files">
</span>
<button type="submit" class="btn btn-primary start">
<i class="fa fa-upload"></i>
<span>{{ trans.startUpload }}</span>
</button>
<button type="reset" class="btn btn-warning cancel">
<i class="fa fa-ban"></i>
<span>{{ trans.cancelUpload }}</span>
</button>
<!-- The loading indicator is shown during file processing -->
<span class="fileupload-loading"></span>
</div>
<!-- The global progress information -->
<div class="col-md-4 fileupload-progress fade">
<!-- The global progress bar -->
<div class="progress">
<div class="progress-bar progress-bar-success progress-bar-striped active" role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width:0%;">
<div class="sr-only"></div>
</div>
</div>
<!-- The extended global progress information -->
<div class="progress-extended">&nbsp;</div>
<!-- Processing info container -->
<div class="progress-end" style="display:none;">{{ trans.processing }}</div>
</div>
</div>
<div class="row">
<div class="col-md-12">
{% endverbatim %}
{% set title %}{% trans "Overwrite existing data?" %}{% endset %}
{% set helpText %}{% trans "Erase all content in this DataSet and overwrite it with the new content in this import." %}{% endset %}
{{ forms.checkbox("overwrite", title, "", helpText) }}
{% set title %}{% trans "Ignore first row?" %}{% endset %}
{% set helpText %}{% trans "Ignore the first row? Useful if the CSV has headings." %}{% endset %}
{{ forms.checkbox("ignorefirstrow", title, "", helpText) }}
{% set message %}{% trans "In the fields below please enter the column number in the CSV file that corresponds to the Column Heading listed. This should be done before Adding the file." %}{% endset %}
{{ forms.message(message) }}
{% verbatim %}
{{#each columns}}
<div class="form-group row">
<label class="col-sm-2 control-label" for="csvImport_{{dataSetColumnId}}">{{heading}}</label>
<div class="col-sm-10">
<input class="form-control" name="csvImport_{{dataSetColumnId}}" type="number" id="csvImport_{{dataSetColumnId}}" value="{{ index }}" />
</div>
</div>
{{/each}}
</div>
</div>
<!-- The table listing the files available for upload/download -->
<table role="presentation" class="table table-striped"><tbody class="files"></tbody></table>
</form>
</script>
<!-- The template to display files available for upload -->
<script id="template-dataset-upload" type="text/x-tmpl">
{% for (var i=0, file; file=o.files[i]; i++) { %}
<tr class="template-upload">
<td>
<span class="fileupload-preview"></span>
</td>
<td class="title">
{% if (file.error) { %}
<div><span class="label label-danger">{%=file.error%}</span></div>
{% } %}
{% if (!file.error) { %}
<label for="name[]"><input name="name[]" type="text" id="name" value="" /></label>
{% } %}
</td>
<td>
<p class="size">{%=o.formatFileSize(file.size)%}</p>
{% if (!o.files.error) { %}
<div class="progress">
<div class="progress-bar progress-bar-striped active" role="progressbar" aria-valuemin="0" aria-valuemax="100" style="width:0%;">
<div class="sr-only"></div>
</div>
</div>
</div>
{% } %}
</td>
<td class="btn-group">
{% if (!o.files.error && !i && !o.options.autoUpload) { %}
<button class="btn btn-primary start">
<i class="fa fa-upload"></i>
</button>
{% } %}
{% if (!i) { %}
<button class="btn btn-warning cancel">
<i class="fa fa-ban"></i>
</button>
{% } %}
</td>
</tr>
{% } %}
</script>
<!-- The template to display files available for download -->
<script id="template-dataset-download" type="text/x-tmpl">
{% for (var i=0, file; file=o.files[i]; i++) { %}
<tr class="template-download">
<td>
<p class="name" id="{%=file.storedas%}" status="{% if (file.error) { %}error{% } %}">
{%=file.name%}
</p>
{% if (file.error) { %}
<div><span class="label label-danger">{%=file.error%}</span></div>
{% } %}
</td>
<td>
<span class="size">{%=o.formatFileSize(file.size)%}</span>
</td>
</tr>
{% } %}
</script>
{% endverbatim %}
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,138 +0,0 @@
/* High-specificity DataTables contrast overrides
Ensures table body text is readable against dark theme backgrounds.
Light text on dark backgrounds (dark mode).
Dark text on light backgrounds (light mode).
*/
/* FIRST: Light mode rules that check actual background colors (not dependent on body class) */
#datatable-container table.dataTable tbody td,
#datatable-container .dataTables_wrapper table.dataTable tbody td,
.ots-table-card table.dataTable tbody td,
.ots-table-card table.dataTable tbody td * {
color: var(--color-text-primary) !important;
opacity: 1 !important;
}
#datatable-container table.dataTable thead th,
.ots-table-card table.dataTable thead th,
#datatable-container table.dataTable thead th * {
color: var(--color-text-secondary) !important;
opacity: 1 !important;
background-color: var(--color-surface) !important;
}
#datatable-container table.dataTable tbody tr.table-success td,
#datatable-container table.dataTable tbody tr.success td,
#datatable-container table.dataTable tbody tr.selected td,
#datatable-container table.dataTable tbody tr.highlight td {
background-color: rgba(16, 185, 129, 0.1) !important;
color: var(--color-text-primary) !important;
}
#datatable-container table.dataTable tbody td .btn,
#datatable-container table.dataTable tbody td .badge,
#datatable-container table.dataTable tbody td .dropdown-toggle {
color: var(--color-text-primary) !important;
}
#datatable-container table.dataTable tbody tr {
background-color: transparent !important;
}
#datatable-container table.dataTable tbody tr:nth-child(even) {
background-color: var(--color-surface-elevated) !important;
}
#datatable-container table.dataTable tbody tr:hover {
background-color: rgba(37, 99, 235, 0.06) !important;
}
.dataTables_wrapper .dataTables_filter input,
.dataTables_wrapper .dataTables_length select,
.dataTables_wrapper .dataTables_paginate .paginate_button {
color: var(--color-text-primary) !important;
background: var(--color-surface) !important;
border-color: var(--color-border) !important;
}
.dataTables_wrapper .dataTables_info,
.dataTables_wrapper .dataTables_filter,
.dataTables_wrapper .dataTables_length,
.dataTables_wrapper .dataTables_paginate {
color: var(--color-text-primary) !important;
}
/* SECOND: Explicit light mode class overrides for when .ots-light-mode is present */
body.ots-light-mode #datatable-container table.dataTable tbody td,
body.ots-light-mode #datatable-container .dataTables_wrapper table.dataTable tbody td,
body.ots-light-mode .ots-table-card table.dataTable tbody td,
body.ots-light-mode .ots-table-card table.dataTable tbody td * {
color: #0f172a !important;
opacity: 1 !important;
}
body.ots-light-mode #datatable-container table.dataTable thead th,
body.ots-light-mode .ots-table-card table.dataTable thead th,
body.ots-light-mode #datatable-container table.dataTable thead th * {
color: #334155 !important;
opacity: 1 !important;
background-color: #f1f5f9 !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr.table-success td,
body.ots-light-mode #datatable-container table.dataTable tbody tr.success td,
body.ots-light-mode #datatable-container table.dataTable tbody tr.selected td,
body.ots-light-mode #datatable-container table.dataTable tbody tr.highlight td {
background-color: rgba(16, 185, 129, 0.1) !important;
color: #0f172a !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody td .btn,
body.ots-light-mode #datatable-container table.dataTable tbody td .badge,
body.ots-light-mode #datatable-container table.dataTable tbody td .dropdown-toggle {
color: #0f172a !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr {
background-color: transparent !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr:nth-child(even) {
background-color: #f1f5f9 !important;
}
body.ots-light-mode #datatable-container table.dataTable tbody tr:hover {
background-color: rgba(37, 99, 235, 0.06) !important;
}
body.ots-light-mode .dataTables_wrapper .dataTables_filter input,
body.ots-light-mode .dataTables_wrapper .dataTables_length select,
body.ots-light-mode .dataTables_wrapper .dataTables_paginate .paginate_button {
color: #0f172a !important;
background: #ffffff !important;
border-color: #e2e8f0 !important;
}
body.ots-light-mode .dataTables_wrapper .dataTables_info,
body.ots-light-mode .dataTables_wrapper .dataTables_filter,
body.ots-light-mode .dataTables_wrapper .dataTables_length,
body.ots-light-mode .dataTables_wrapper .dataTables_paginate {
color: #0f172a !important;
}
#datatable-container table.dataTable tbody td img,
#datatable-container table.dataTable tbody td svg {
filter: none !important;
}
#datatable-container table.dataTable thead th.sorting:after,
#datatable-container table.dataTable thead th.sorting_asc:after,
#datatable-container table.dataTable thead th.sorting_desc:after {
color: rgba(255,255,255,0.7) !important;
}
.ots-table-card table.dataTable tbody tr td,
.ots-table-card table.dataTable tbody tr td * {
-webkit-text-fill-color: var(--color-text-primary) !important;
}

View File

@@ -1,261 +0,0 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Dayparting"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Dayparting" %}</h1>
<p class="text-muted">{% trans "Manage time-based scheduling rules." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Dayparts" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Retired" %}{% endset %}
{% set option1 = "Yes"|trans %}
{% set option2 = "No"|trans %}
{% set values = [{id: 1, value: option1}, {id: 0, value: option2}] %}
{{ inline.dropdown("isRetired", "single", title, 0, values, "id", "value") }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.featureEnabled("daypart.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Daypart" %}" href="{{ url_for("daypart.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="dayparts" class="table table-striped" data-state-preference-name="daypartGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Start Time" %}</th>
<th>{% trans "End Time" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#dayparts").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("daypart.search") }}",
"data": function(d) {
$.extend(d, $("#dayparts").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "name", "render": dataTableSpacingPreformatted , responsivePriority: 2},
{ "data": "description" },
{ "data": "startTime" },
{ "data": "endTime" },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#dayparts_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function dayPartFormOpen(dialog) {
// Render a set of exceptions
$exceptions = $(dialog).find("#dayPartExceptions");
// Days of the week translations
var daysOfTheWeek = [
{ day: "Mon", title: "{% trans "Monday" %}" },
{ day: "Tue", title: "{% trans "Tuesday" %}" },
{ day: "Wed", title: "{% trans "Wednesday" %}" },
{ day: "Thu", title: "{% trans "Thursday" %}" },
{ day: "Fri", title: "{% trans "Friday" %}" },
{ day: "Sat", title: "{% trans "Saturday" %}" },
{ day: "Sun", title: "{% trans "Sunday" %}" }
];
// Compile the handlebars template
var exceptionsTemplate = Handlebars.compile($("#dayPartExceptionsTemplate").html());
if (dialog.data().extra.exceptions.length == 0) {
// Contexts for template
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: "fa-plus",
exceptionDay: "",
exceptionStart: "",
exceptionEnd: "",
fieldId: 0
};
// Append
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
} else {
// For each of the existing exceptions, create form components
var i = 0;
$.each(dialog.data().extra.exceptions, function (index, field) {
i++;
// call the template
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: ((i == 1) ? "fa-plus" : "fa-minus"),
exceptionDay: field.day,
exceptionStart: field.start,
exceptionEnd: field.end,
fieldId: i
};
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
});
}
// Nabble the resulting buttons
$exceptions.on("click", "button", function (e) {
e.preventDefault();
// find the gylph
if ($(this).find("i").hasClass("fa-plus")) {
var context = {
daysOfWeek: daysOfTheWeek,
buttonGlyph: "fa-minus",
exceptionDay: "",
exceptionStart: "",
exceptionEnd: "",
fieldId: $exceptions.find('.form-group').length + 1
};
$exceptions.append(exceptionsTemplate(context));
XiboInitialise("#" + $exceptions.prop("id"));
} else {
// Remove this row
$(this).closest(".form-group").remove();
}
});
// check if we already have this day in exceptions array, if so remove the row with a message.
$exceptions.on("change", "select", function() {
var selectedDays = [];
$('select').not('#' + $(this).attr('id')).each(function(i) {
selectedDays.push($(this).val());
});
if (selectedDays.includes(this.value)) {
toastr.error(translations.dayPartExceptionErrorMessage);
// Remove this row
$(this).closest(".form-group").remove();
}
})
}
// Equals helper for the templates below
Handlebars.registerHelper('eq', function(v1, v2, opts) {
if (v1 === v2) {
return opts.fn(this);
} else {
return opts.inverse(this);
}
});
</script>
{% verbatim %}
<script type="text/x-handlebars-template" id="dayPartExceptionsTemplate">
<div class="form-group row">
<div class="col-3">
<select class="form-control" name="exceptionDays[]" id="exceptionDays_{{fieldId}}">
<option value=""></option>
{{#each daysOfWeek}}
<option value="{{ day }}" {{#eq day ../exceptionDay}}selected{{/eq}}>{{ title }}</option>
{{/each}}
</select>
</div>
<div class="col-3">
{% endverbatim %}
{{ inline.time("exceptionStartTimes[]", "", "{{ exceptionStart }}" ) }}
{% verbatim %}
</div>
<div class="col-3">
{% endverbatim %}
{{ inline.time("exceptionEndTimes[]", "", "{{ exceptionEnd }}" ) }}
{% verbatim %}
</div>
<div class="col-1">
<button class="btn btn-white"><i class="fa {{ buttonGlyph }}"></i></button>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

View File

@@ -1,499 +0,0 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Displays"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block headContent %}
{# Add page source code bundle ( CSS ) #}
<script nonce="{{ cspNonce }}">
(function(){
try{
var stored = localStorage.getItem('ots-theme-mode');
var prefersLight = window.matchMedia && window.matchMedia('(prefers-color-scheme: light)').matches;
var mode = stored || (prefersLight ? 'light' : 'light');
if(mode === 'light') document.documentElement.classList.add('ots-light-mode');
else document.documentElement.classList.remove('ots-light-mode');
}catch(e){}
})();
(function(){
// Apply collapsed sidebar state early to prevent header flashing
try {
var collapsed = localStorage.getItem('otsTheme:sidebarCollapsed');
if (collapsed === 'true') {
try { console.debug && console.debug('otsTheme:sidebarCollapsed early (page):', collapsed); } catch(e){}
document.documentElement.classList.add('ots-sidebar-collapsed');
if (document.body) document.body.classList.add('ots-sidebar-collapsed');
try { console.debug && console.debug('applied ots-sidebar-collapsed early (page)'); } catch(e){}
} else {
try { console.debug && console.debug('otsTheme:sidebarCollapsed early (page): not set'); } catch(e){}
}
} catch (e) {}
})();
</script>
<style nonce="{{ cspNonce }}">html,body{background:#ffffff!important;color:#111111!important}
/* Hide the topbar strip entirely — actions are now in .ots-page-actions */
.row.header.header-side,
.ots-topbar-strip { display: none !important; height: 0 !important; margin: 0 !important; padding: 0 !important; overflow: hidden !important; }
</style>
<link rel="stylesheet" href="{{ theme.rootUri() }}dist/pages/display-page.bundle.min.css?v={{ version }}&rev={{revision }}">
{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Displays" %}</h1>
<p class="text-muted">{% trans "Manage your player fleet and status." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="displayView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Displays" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#filter-general" role="tab" data-toggle="tab">{% trans "General" %}</a></li>
<li class="nav-item"><a class="nav-link" href="#filter-advanced" role="tab" data-toggle="tab">{% trans "Advanced" %}</a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="filter-general">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("displayId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('display', title) }}
{% set title %}{% trans "Status" %}{% endset %}
{% set check %}{% trans "Up to date" %}{% endset %}
{% set cross %}{% trans "Downloading" %}{% endset %}
{% set cloud %}{% trans "Out of date" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: check},
{ optionid: "2", option: cross},
{ optionid: "3", option: cloud}
] %}
{{ inline.dropdown("mediaInventoryStatus", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Logged In?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: yesOption},
{ optionid: "0", option: noOption}
] %}
{{ inline.dropdown("loggedIn", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Authorised?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: yesOption },
{ optionid: "0", option: noOption},
] %}
{{ inline.dropdown("authorised", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "XMR Registered?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 1, option: yesOption},
{ optionid: 0, option: noOption},
] %}
{{ inline.dropdown("xmrRegistered", "single", title, "", options, "optionid", "option") }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% if currentUser.featureEnabled("displaygroup.view") %}
{% set title %}{% trans "Display Group" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("displayGroup.search") },
{ name: "data-filter-options", value: '{"isDisplaySpecific":0}' },
{ name: "data-search-term", value: "displayGroup" },
{ name: "data-id-property", value: "displayGroupId" },
{ name: "data-text-property", value: "displayGroup" },
{ name: "data-initial-key", value: "displayGroupId" },
] %}
{{ inline.dropdown("displayGroupId", "single", title, "", null, "displayGroupId", "displayGroup", helpText, "pagedSelect", "", "", "", attributes) }}
{% endif %}
{% if currentUser.featureEnabled("displayprofile.view") %}
{% set title %}{% trans "Display Profile" %}{% endset %}
{{ inline.dropdown("displayProfileId", "single", title, "", [{displayProfileId:null, name:""}]|merge(displayProfiles), "displayProfileId", "name") }}
{% endif %}
{{ inline.hidden("folderId") }}
</div>
<div class="tab-pane" id="filter-advanced">
{% set title %}{% trans "Last Accessed" %}{% endset %}
{{ inline.date("lastAccessed", title) }}
{% set title %}{% trans "Player Type" %}{% endset %}
{% set android %}{% trans "Android" %}{% endset %}
{% set chromeos %}{% trans "ChromeOS" %}{% endset %}
{% set windows %}{% trans "Windows" %}{% endset %}
{% set webos %}{% trans "webOS" %}{% endset %}
{% set sssp %}{% trans "Tizen" %}{% endset %}
{% set linux %}{% trans "Linux" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "android", option: android},
{ optionid: "chromeos", option: chromeos},
{ optionid: "windows", option: windows},
{ optionid: "lg", option: webos},
{ optionid: "sssp", option: sssp},
{ optionid: "linux", option: linux},
] %}
{{ inline.dropdown("clientType", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Player Code" %}{% endset %}
{{ inline.input("clientCode", title) }}
{% set title %}{% trans "Custom ID" %}{% endset %}
{{ inline.input("customId", title) }}
{% set title %}{% trans "Mac Address" %}{% endset %}
{{ inline.input("macAddress", title) }}
{% set title %}{% trans "IP Address" %}{% endset %}
{{ inline.input("clientAddress", title) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set landscape %}{% trans "Landscape" %}{% endset %}
{% set portrait %}{% trans "Portrait" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "landscape", option: landscape},
{ optionid: "portrait", option: portrait}
] %}
{{ inline.dropdown("orientation", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Commercial Licence" %}{% endset %}
{% set licensed %}{% trans "Licensed fully" %}{% endset %}
{% set trial %}{% trans "Trial" %}{% endset %}
{% set notLinceced %}{% trans "Not licenced" %}{% endset %}
{% set notApplicable %}{% trans "Not applicable" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: "1", option: licensed},
{ optionid: "2", option: trial},
{ optionid: "0", option: notLinceced},
{ optionid: "3", option: notApplicable}
] %}
{{ inline.dropdown("commercialLicence", "single", title, "", options, "optionid", "option") }}
{% set title %}{% trans "Player supported?" %}{% endset %}
{% set yesOption %}{% trans "Yes" %}{% endset %}
{% set noOption %}{% trans "No" %}{% endset %}
{% set options = [
{ optionid: "", option: "" },
{ optionid: 1, option: yesOption},
{ optionid: 0, option: noOption},
] %}
{{ inline.dropdown("isPlayerSupported", "single", title, "", options, "optionid", "option") }}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{{ "Open / Close Folder Search options"|trans }}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
<button type="button" id="map_button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{{ "Map"|trans }}"><i class="fa fa-map"></i></button>
<button type="button" id="list_button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{{ "List"|trans }}"><i class="fa fa-list"></i></button>
{% if currentUser.featureEnabled("displays.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a Display via user_code displayed on the Player screen" %}" href="{{ url_for("display.addViaCode.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="displays" class="table table-striped" data-content-type="display" data-content-id-name="displayId" data-state-preference-name="displayGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Display" %}</th>
<th>{% trans "Display Type" %}</th>
<th>{% trans "Address" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Authorised?" %}</th>
<th>{% trans "Current Layout" %}</th>
<th>{% trans "Storage Available" %}</th>
<th>{% trans "Storage Total" %}</th>
<th>{% trans "Storage Free %" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Orientation" %}</th>
<th>{% trans "Resolution" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Default Layout" %}</th>
<th>{% trans "Interleave Default" %}</th>
<th>{% trans "Email Alert" %}</th>
<th>{% trans "Logged In" %}</th>
<th>{% trans "Last Accessed" %}</th>
<th>{% trans "Display Profile" %}</th>
<th>{% trans "Version" %}</th>
<th>{% trans "Supported?" %}</th>
<th>{% trans "Device Name" %}</th>
<th>{% trans "IP Address" %}</th>
<th>{% trans "Mac Address" %}</th>
<th>{% trans "Timezone" %}</th>
<th>{% trans "Languages" %}</th>
<th>{% trans "Latitude" %}</th>
<th>{% trans "Longitude" %}</th>
<th>{% trans "Screen shot?" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "CMS Transfer?" %}</th>
<th>{% trans "Bandwidth Limit" %}</th>
<th>{% trans "Last Command" %}</th>
<th>{% trans "XMR Registered" %}</th>
<th>{% trans "Commercial Licence" %}</th>
<th>{% trans "Remote" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Screen Size" %}</th>
<th>{% trans "Is Mobile?" %}</th>
<th>{% trans "Outdoor?" %}</th>
<th>{% trans "Reference 1" %}</th>
<th>{% trans "Reference 2" %}</th>
<th>{% trans "Reference 3" %}</th>
<th>{% trans "Reference 4" %}</th>
<th>{% trans "Reference 5" %}</th>
<th>{% trans "Custom ID" %}</th>
<th>{% trans "Cost Per Play" %}</th>
<th>{% trans "Impressions Per Play" %}</th>
<th>{% trans "Created Date" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Faults?" %}</th>
<th>{% trans "OS Version" %}</th>
<th>{% trans "OS SDK" %}</th>
<th>{% trans "Manufacturer" %}</th>
<th>{% trans "Brand" %}</th>
<th>{% trans "Model" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
<!-- Map -->
<div class="row" id="map-view-container" style="display:none;">
<div class="col-sm-12">
<div class="map-legend" style="display:none; position: absolute; z-index: 500; right: 20px; top: 10px;">
<div class="display-map-legend" style="font-size: 12px;">
<div>Logged in</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-green-check.png'/> - Up to date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-yellow-check.png'/> - Out of date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-red-check.png'/> - Downloading/Unknown</div>
</br>
<div>Logged out</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-green-cross.png'/> - Up to date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-yellow-cross.png'/> - Out of date</div>
<div><img style="width: 15%" src='{{ theme.rootUri() }}dist/assets/map-marker-red-cross.png'/> - Downloading/Unknown</div>
</div>
</div>
<div id="display-map" class="content-card ots-map-card" data-displays-url="{{ url_for("display.map") }}">
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables and translations #}
<script type="text/javascript" nonce="{{ cspNonce }}" defer>
{# JS variables #}
var publicPath = "{{ theme.rootUri() }}";
var displaySearchURL = "{{ url_for('display.search') }}";
var layoutSearchURL = "{{ url_for('layout.search') }}";
var mapConfig = {{ mapConfig| json_encode | raw }};
var playerVersionSupport = "{{playerVersion}}";
var folderViewEnabled = "{{ currentUser.featureEnabled('folder.view') }}";
var taggingEnabled = "{{ currentUser.featureEnabled('tag.tagging') }}";
var showThumbnailColumn = "{{ currentUser.getOptionValue('showThumbnailColumn', 1) }}";
var SHOW_DISPLAY_AS_VNCLINK = "{{ settings.SHOW_DISPLAY_AS_VNCLINK }}";
var SHOW_DISPLAY_AS_VNC_TGT = "{{ settings.SHOW_DISPLAY_AS_VNC_TGT }}";
{# Custom translations #}
var displayPageTrans = {
back: "{% trans "Back" %}",
yes: "{% trans "Yes" %}",
no: "{% trans "No" %}",
daysOfTheWeek: {
monday: "{% trans "Monday" %}",
tuesday: "{% trans "Tuesday" %}",
wednesday: "{% trans "Wednesday" %}",
thursday: "{% trans "Thursday" %}",
friday: "{% trans "Friday" %}",
saturday: "{% trans "Saturday" %}",
sunday: "{% trans "Sunday" %}",
},
playerStatusWindow: "{% trans "Player Status Window" %}",
VNCtoThisDisplay: "{% trans "VNC to this Display" %}",
TeamViewertoThisDisplay: "{% trans "TeamViewer to this Display" %}",
WebkeytoThisDisplay: "{% trans "Webkey to this Display" %}",
};
</script>
{# Add page source code bundle ( JS ) #}
<script src="{{ theme.rootUri() }}dist/leaflet.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/pages/display-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{# Initialize map/list view toggle AFTER all other scripts load #}
<script type="text/javascript" nonce="{{ cspNonce }}">
function initMapListToggle() {
var mapBtn = document.getElementById('map_button');
var listBtn = document.getElementById('list_button');
var mapViewContainer = document.getElementById('map-view-container');
// DataTables wraps the <table> in a div with id "displays_wrapper"
var tableWrapper = document.getElementById('displays_wrapper');
// Fallback: if DataTables hasn't wrapped it yet, target the table itself
if (!tableWrapper) {
tableWrapper = document.getElementById('displays');
}
if (!mapBtn || !listBtn || !mapViewContainer || !tableWrapper) {
console.warn('Map/list toggle: required elements not found:', {
mapBtn: !!mapBtn,
listBtn: !!listBtn,
mapViewContainer: !!mapViewContainer,
tableWrapper: !!tableWrapper
});
return;
}
console.log('Map/list toggle initialized');
// Show list view by default
tableWrapper.style.display = '';
mapViewContainer.style.display = 'none';
listBtn.classList.add('active');
mapBtn.classList.remove('active');
// Map button click handler
mapBtn.addEventListener('click', function(e) {
e.preventDefault();
console.log('Map button clicked');
mapViewContainer.style.display = 'block';
tableWrapper.style.display = 'none';
mapBtn.classList.add('active');
listBtn.classList.remove('active');
// Leaflet can't size itself in a hidden container.
// After making the map visible, tell every Leaflet map
// instance inside it to recalculate its dimensions.
setTimeout(function() {
var mapEl = document.getElementById('display-map');
if (mapEl) {
// Leaflet stores its instance on the DOM element as _leaflet_map
var leafletKeys = Object.keys(mapEl).filter(function(k) {
return k.indexOf('_leaflet_map') === 0 || k === '_leaflet';
});
// Try the standard _leaflet_map key
if (mapEl._leaflet_map) {
mapEl._leaflet_map.invalidateSize();
console.log('Leaflet invalidateSize called via _leaflet_map');
}
// Also try iterating over Leaflet-stamped keys
for (var i = 0; i < leafletKeys.length; i++) {
var inst = mapEl[leafletKeys[i]];
if (inst && typeof inst.invalidateSize === 'function') {
inst.invalidateSize();
console.log('Leaflet invalidateSize called via', leafletKeys[i]);
}
}
// Fallback: dispatch a resize event so Leaflet picks it up
window.dispatchEvent(new Event('resize'));
}
}, 200);
});
// List button click handler
listBtn.addEventListener('click', function(e) {
e.preventDefault();
console.log('List button clicked');
tableWrapper.style.display = '';
mapViewContainer.style.display = 'none';
listBtn.classList.add('active');
mapBtn.classList.remove('active');
});
}
// Wait for DOM to be ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function() {
// Give the page bundle time to initialize DataTables
setTimeout(initMapListToggle, 500);
});
} else {
// Give the page bundle time to initialize DataTables
setTimeout(initMapListToggle, 500);
}
</script>
{% endblock %}

View File

@@ -1,377 +0,0 @@
{#
/**
* Copyright (C) 2020-2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Display Groups"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Display Groups" %}</h1>
<p class="text-muted">{% trans "Organize Displays into logical groups." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="displayGroupGridView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Display Groups" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.input("displayGroupId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('displayGroup', title) }}
{% set title %}{% trans "Display" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("display.search") },
{ name: "data-search-term", value: "display" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "displayId" },
{ name: "data-text-property", value: "display" },
{ name: "data-initial-key", value: "displayId" },
] %}
{% set helpText %}{% trans "Return Display Groups that directly contain the selected Display." %}{% endset %}
{{ inline.dropdown("displayId", "single", title, "", null, "displayId", "display", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Nested Display" %}{% endset %}
{% set helpText %}{% trans "Return Display Groups that contain the selected Display somewhere in the nested Display Group relationship tree." %}{% endset %}
{{ inline.dropdown("nestedDisplayId", "single", title, "", null, "displayId", "display", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Dynamic Criteria" %}{% endset %}
{{ inline.input("dynamicCriteria", title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("displaygroup.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add Display Group" %}" href="{{ url_for("displayGroup.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="displaygroups" class="table table-striped" data-content-type="displayGroup" data-content-id-name="displayGroupId" data-state-preference-name="displayGroupGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Is Dynamic?" %}</th>
<th>{% trans "Criteria" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}
<th>{% trans "Criteria Tags" %}</th>
<th>{% trans "Tags" %}</th>
{% endif %}
<th>{% trans "Sharing" %}</th>
<th>{% trans "Reference 1" %}</th>
<th>{% trans "Reference 2" %}</th>
<th>{% trans "Reference 3" %}</th>
<th>{% trans "Reference 4" %}</th>
<th>{% trans "Reference 5" %}</th>
<th>{% trans "Created Date" %}</th>
<th>{% trans "Modified Date" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var displayGroupTable;
var displayTable;
var criteria;
var criteriaTag;
var useRegexForName;
var exactTags;
var logicalOperator;
var logicalOperatorName;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
displayGroupTable = $("#displaygroups").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
"filter": false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("displayGroup.search") }}",
"data": function(d) {
$.extend(d, $("#displaygroups").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "displayGroupId", responsivePriority: 2},
{ "data": "displayGroup", "render": dataTableSpacingPreformatted, responsivePriority: 2 },
{ "data": "description", responsivePriority: 3 },
{ "data": "isDynamic", "render": dataTableTickCrossColumn, responsivePriority: 3 },
{ "data": "dynamicCriteria", responsivePriority: 4 },
{% if currentUser.featureEnabled("tag.tagging") %}
{ "data": "dynamicCriteriaTags", responsivePriority: 4},
{
"name": "tags",
"sortable": false,
responsivePriority: 3,
"data": dataTableCreateTags
},
{% endif %}
{
"data": "groupsWithPermissions",
visible: false,
responsivePriority: 10,
"render": dataTableCreatePermissions
},
{ "data": "ref1", "visible": false, responsivePriority: 5},
{ "data": "ref2", "visible": false, responsivePriority: 5},
{ "data": "ref3", "visible": false, responsivePriority: 5},
{ "data": "ref4", "visible": false, responsivePriority: 5},
{ "data": "ref5", "visible": false, responsivePriority: 5},
{ "data": "createdDt", "visible": false, responsivePriority: 5 },
{ "data": "modifiedDt", "visible": false, responsivePriority: 5 },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
$("#refreshGrid").click(function () {
displayGroupTable.ajax.reload();
});
displayGroupTable.on('draw', dataTableDraw);
displayGroupTable.on('draw', { form: $("#displaygroups").closest(".XiboGrid").find(".FilterDiv form") }, dataTableCreateTagEvents);
displayGroupTable.on('processing.dt', dataTableProcessing);
dataTableAddButtons(displayGroupTable, $('#displaygroups_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
displayGroupTable.ajax.reload();
});
});
function setDeleteMultiSelectFormOpen(dialog) {
$(dialog).find('.save-button').prop('disabled', false);
var template = Handlebars.compile($('#template-display-group-multi-delete-checkbox').html());
var $input = $(template());
$input.find('input').on('change', function() {
$(dialog).find('.save-button').prop('disabled', !$(this).is(':checked'));
});
$(dialog).find('.modal-body').append($input);
}
function displayGroupAddFormNext() {
// Get form
var $form = $("#displayGroupAddForm");
// Set apply and apply reset data
$form.data("apply", true);
$form.data("applyCallback", 'applyResetCallback');
// Submit form
$form.submit();
}
function applyResetCallback(form) {
// Reset form fields
$(form).find('#displayGroup').val("");
}
function displayGroupFormOpen(dialog) {
displayTable = null;
$(dialog).find("input[name=dynamicCriteria]").on("keyup", _.debounce(function() {
displayGroupQueryDynamicMembers(dialog);
}, 500));
$(dialog).find("input[name=dynamicCriteriaTags], input[name=exactTags], select[name=logicalOperator], select[name=logicalOperatorName]").change(function() {
displayGroupQueryDynamicMembers(dialog);
});
var $form = $('#displayGroupAddForm');
// First time in there
displayGroupQueryDynamicMembers(dialog);
}
function displayGroupQueryDynamicMembers(dialog) {
if ($(dialog).find("input[name=isDynamic]")[0].checked) {
criteria = $(dialog).find("input[name=dynamicCriteria]").val();
criteriaTag = $(dialog).find("input[name=dynamicCriteriaTags]").val();
useRegexForName = $(dialog).find("input[name=useRegexForName]").val();
exactTags = $(dialog).find("input[name=exactTags]").is(':checked');
logicalOperator = $(dialog).find("select[name=logicalOperator]").val();
logicalOperatorName = $(dialog).find("select[name=logicalOperatorName]").val();
if (criteria === "" && criteriaTag === "") {
if (displayTable != null) {
displayTable.destroy();
displayTable = null;
$("#displayGroupDisplays tbody").empty();
}
return;
}
if (displayTable != null) {
displayTable.ajax.reload();
} else {
displayTable = $("#displayGroupDisplays").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("display.search") }}",
"data": function (d) {
$.extend(
d,
{
display: criteria,
tags: criteriaTag,
useRegexForName: useRegexForName,
exactTags: exactTags,
logicalOperator: logicalOperator,
logicalOperatorName: logicalOperatorName
}
);
}
},
"columns": [
{"data": "displayId"},
{"data": "display"},
{"data": dataTableCreateTags},
{
"data": "mediaInventoryStatus",
"render": function (data, type, row) {
if (type != "display")
return data;
var icon = "";
if (data == 1)
icon = "fa-check";
else if (data == 0)
icon = "fa-times";
else
icon = "fa-cloud-download";
return "<span class='fa " + icon + "'></span>";
}
},
{"data": "licensed", "render": dataTableTickCrossColumn}
]
});
displayTable.on('processing.dt', dataTableProcessing);
displayTable.on('draw', { form: $(".displayGroupForm") }, dataTableCreateTagEvents);
}
}
}
</script>
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-display-group-multi-delete-checkbox">
<div class="form-group row">
<div class="offset-sm-2 col-sm-10 mt-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="checkbox-confirmDelete" name="confirmDelete">
<label class="form-check-label" for="checkbox-confirmDelete">
{% endverbatim %}{{ "Are you sure you want to delete?"|trans }}{% verbatim %}
</label>
</div>
<small class="form-text text-muted">{% endverbatim %}{{ "Check to confirm deletion of the selected records."|trans }}{% verbatim %}</small>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

View File

@@ -1,167 +0,0 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Display Setting Profiles"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Display Settings" %}</h1>
<p class="text-muted">{% trans "Manage Display settings profiles." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Display Settings" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('displayProfile', title) }}
{% set title %}{% trans "Type" %}{% endset %}
{{ inline.dropdown("type", "single", title, "", [{typeId:null, type:""}]|merge(types), "typeId","type") }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.featureEnabled("displayprofile.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Display Settings Profile" %}" href="{{ url_for("displayProfile.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="displayProfiles" class="table table-striped" data-state-preference-name="displayProfileGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Default" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#displayProfiles").DataTable({ "language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("displayProfile.search") }}",
"data": function(d) {
$.extend(d, $("#displayProfiles").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "name", "render": dataTableSpacingPreformatted , responsivePriority: 2},
{ "data": "type" },
{ "data": "isDefault", "render": dataTableTickCrossColumn },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#displayProfiles_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
// Custom submit for display profile form
function displayProfileEditFormSubmit() {
var $form = $("#displayProfileForm");
// Remove temp fields and enable checkbox after submit
$form.submit(function(event) {
event.preventDefault();
// Re-enable checkboxes
$form.find('input[type="checkbox"]').each(function () {
// Enable checkbox
$(this).attr('disabled', false);
});
// Remove temp input fields
$form.find('input.temp-input').each(function () {
$(this).remove();
});
});
// Replace all checkboxes with hidden input fields
$form.find('input[type="checkbox"]').each(function () {
// Get checkbox values
var value = $(this).is(':checked') ? 'on' : 'off';
var id = $(this).attr('id');
// Create hidden input
$('<input type="hidden" class="temp-input">')
.attr('id', id)
.attr('name', id)
.val(value)
.appendTo($(this).parent());
// Disable checkbox so it won't be submitted
$(this).attr('disabled', true);
});
// Submit form
$form.submit();
}
</script>
{% endblock %}

View File

@@ -1,192 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% trans "Error" %} | {{ theme.getThemeConfig("theme_title") }}</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="public-path" content="{{ theme.rootUri() }}"/>
<link rel="shortcut icon" href="{{ theme.uri("img/favicon.ico") }}" />
<script src="{{ theme.rootUri() }}dist/style.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<link href="{{ theme.uri("css/override.css") }}?{{ version }}" rel="stylesheet" media="screen">
<link href="{{ theme.uri("css/override-dark.css") }}?{{ version }}" rel="stylesheet" media="screen">
<style type="text/css" nonce="{{ cspNonce }}">
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
background-color: #0f172a;
color: #f1f5f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.error-page {
text-align: center;
padding: 40px 24px;
max-width: 560px;
width: 100%;
}
.error-logo {
height: 48px;
width: auto;
margin-bottom: 32px;
}
.error-icon {
width: 64px;
height: 64px;
border-radius: 50%;
background-color: rgba(239, 68, 68, 0.15);
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto 20px;
color: #ef4444;
}
.error-title {
font-size: 1.5rem;
font-weight: 600;
color: #ffffff;
margin: 0 0 16px;
}
.error-detail {
background-color: rgba(239, 68, 68, 0.1);
border: 1px solid rgba(239, 68, 68, 0.25);
border-radius: 8px;
padding: 14px 18px;
font-size: 0.9rem;
color: #fca5a5;
text-align: left;
margin-bottom: 28px;
line-height: 1.6;
word-break: break-word;
}
.error-fallback {
font-size: 0.95rem;
color: #94a3b8;
margin: 0 0 28px;
line-height: 1.6;
}
.error-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.btn-home {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background-color: #e87800;
color: #ffffff;
text-decoration: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-home:hover {
background-color: #c46500;
color: #ffffff;
text-decoration: none;
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background-color: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
text-decoration: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.12);
cursor: pointer;
transition: background-color 0.2s;
}
.btn-back:hover {
background-color: rgba(255, 255, 255, 0.14);
color: #ffffff;
text-decoration: none;
}
.redirect-notice {
margin-top: 28px;
font-size: 0.8rem;
color: #64748b;
}
.redirect-notice span {
color: #e87800;
font-weight: 600;
}
.error-divider {
width: 48px;
height: 3px;
background: #ef4444;
border-radius: 2px;
margin: 16px auto 24px;
}
</style>
</head>
<body>
<div class="error-page" role="main">
<a href="{{ homeUrl }}">
<img class="error-logo" src="{{ theme.uri("img/xibologo.png") }}" alt="OTS Signs">
</a>
<div class="error-icon" aria-hidden="true">
<svg xmlns="http://www.w3.org/2000/svg" width="28" height="28" fill="currentColor" viewBox="0 0 16 16">
<path d="M8.982 1.566a1.13 1.13 0 0 0-1.96 0L.165 13.233c-.457.778.091 1.767.98 1.767h13.713c.889 0 1.438-.99.98-1.767L8.982 1.566zM8 5c.535 0 .954.462.9.995l-.35 3.507a.552.552 0 0 1-1.1 0L7.1 5.995A.905.905 0 0 1 8 5zm.002 6a1 1 0 1 1 0 2 1 1 0 0 1 0-2z"/>
</svg>
</div>
<h1 class="error-title">{% trans "Something Went Wrong" %}</h1>
<div class="error-divider" aria-hidden="true"></div>
{% if message is defined and message != "" %}
<div class="error-detail" role="alert">{{ message }}</div>
{% else %}
<p class="error-fallback">
{% trans "An unexpected error occurred. Please try again or contact support if the problem persists." %}
</p>
{% endif %}
<div class="error-actions">
<a class="btn-home" href="{{ homeUrl }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L8.354 1.146z"/></svg>
{% trans "Go to Dashboard" %}
</a>
<button class="btn-back" onclick="history.back()" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/></svg>
{% trans "Go Back" %}
</button>
</div>
<p class="redirect-notice" id="redirect-notice" aria-live="polite">
{% trans "Redirecting to dashboard in" %} <span id="countdown">15</span> {% trans "seconds" %}&hellip;
</p>
</div>
<script src="{{ theme.rootUri() }}dist/vendor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script type="text/javascript" nonce="{{ cspNonce }}">
(function () {
var homeUrl = {{ homeUrl | json_encode | raw }};
var seconds = 15;
var el = document.getElementById('countdown');
var interval = setInterval(function () {
seconds -= 1;
if (el) el.textContent = seconds;
if (seconds <= 0) {
clearInterval(interval);
window.location.replace(homeUrl);
}
}, 1000);
}());
</script>
</body>
</html>

View File

@@ -1,159 +0,0 @@
{#
/*
* OTS Signs Theme - Fonts Page
* Based on Xibo CMS fonts-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Fonts"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Fonts" %}</h1>
<p class="text-muted">{% trans "Manage fonts for your signage content." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="fontView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Fonts" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("id", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.featureEnabled("font.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn" href="#" id="fontUploadForm" title="{% trans "Add a new Font" %}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="fonts" class="table table-striped" data-state-preference-name="fontGrid">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "name" %}</th>
<th>{% trans "File Name" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Modified By" %}</th>
<th>{% trans "Size" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var fontsTable;
$(document).ready(function() {
fontsTable = $("#fonts").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("font.search") }}",
data: function (d) {
$.extend(d, $("#fonts").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "id", responsivePriority: 2},
{"data": "name", responsivePriority: 2},
{"data": "fileName", responsivePriority: 4},
{"data": "createdAt", responsivePriority: 3},
{"data": "modifiedAt", responsivePriority: 3},
{"data": "modifiedBy", responsivePriority: 3},
{
"name": "size",
responsivePriority: 3,
"data": null,
"render": {"_": "size", "display": "fileSizeFormatted", "sort": "size"}
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
fontsTable.on('draw', dataTableDraw);
fontsTable.on('processing.dt', dataTableProcessing);
dataTableAddButtons(fontsTable, $('#fonts_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
fontsTable.ajax.reload();
});
});
$("#fontUploadForm").click(function(e) {
e.preventDefault();
openUploadForm({
url: "{{ url_for("font.add") }}",
title: "{% trans "Add Font" %}",
initialisedBy: "font-upload",
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function () {
fontsTable.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
includeTagsInput: false,
trans: {
addFiles: "{% trans "Add files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ validExt }}"
},
}
});
});
</script>
{% endblock %}

File diff suppressed because one or more lines are too long

View File

@@ -1,802 +0,0 @@
{#
/**
* OTS Signage — Modern Upload Media Modal
* Replaces the core Xibo include-file-upload.twig with a redesigned,
* drag-and-drop, multi-file upload experience.
*
* Reuses the existing openUploadForm(options) API so every page
* (library, layout, fonts, player software, dataset, etc.) keeps working
* without any caller changes.
*
* Dependencies already present in Xibo: jQuery, jQuery UI, jQuery File Upload,
* Bootstrap 4 modal, moment.js.
*/
#}
{# ── Upload Modal Markup ────────────────────────────────────────────────── #}
<div class="modal fade ots-upload-modal" id="ots-upload-modal" tabindex="-1"
role="dialog" aria-labelledby="ots-upload-modal-title" aria-modal="true">
<div class="modal-dialog modal-lg modal-dialog-centered" role="document">
<div class="modal-content ots-upload-content">
{# Header #}
<div class="modal-header ots-upload-header">
<h5 class="modal-title ots-upload-title" id="ots-upload-modal-title"></h5>
<button type="button" class="ots-upload-close" data-dismiss="modal" aria-label="Close">
<svg width="20" height="20" viewBox="0 0 20 20" fill="none"><path d="M15 5L5 15M5 5l10 10" stroke="currentColor" stroke-width="2" stroke-linecap="round"/></svg>
</button>
</div>
{# Body #}
<div class="modal-body ots-upload-body">
{# Tab switcher: File / URL #}
<div class="ots-upload-tabs" id="ots-upload-tabs">
<button type="button" class="ots-upload-tab active" data-tab="file" id="ots-tab-file">
<i class="fas fa-file-upload"></i> File
</button>
<button type="button" class="ots-upload-tab" data-tab="url" id="ots-tab-url">
<i class="fas fa-link"></i> URL
</button>
</div>
{# ── FILE TAB ──────────────────────────────────────────────── #}
<div class="ots-upload-tab-content" id="ots-upload-tab-file">
{# Folder selector row shown only when options.folderSelector is true #}
<div class="ots-upload-folder-row d-none" id="ots-upload-folder-row">
<span class="ots-upload-folder-label" id="ots-upload-folder-label"></span>
<button type="button" class="btn btn-sm ots-upload-folder-btn" id="ots-upload-folder-btn" title="">
<i class="fas fa-folder-open"></i> <span id="ots-upload-folder-text"></span>
</button>
</div>
{# Max file size notice #}
<div class="ots-upload-notice d-none" id="ots-upload-size-notice"></div>
{# Drop-zone #}
<form id="ots-upload-form" enctype="multipart/form-data" method="POST">
<div class="ots-upload-dropzone" id="ots-upload-dropzone" role="button" tabindex="0"
aria-label="Drag and drop files here or click to browse">
<div class="ots-upload-dropzone-inner">
<div class="ots-upload-dropzone-icon">
<svg width="48" height="48" viewBox="0 0 48 48" fill="none">
<rect x="4" y="8" width="40" height="32" rx="6" stroke="currentColor" stroke-width="2" fill="none"/>
<path d="M24 30V18m0 0l-6 6m6-6l6 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<p class="ots-upload-dropzone-text">
<strong id="ots-upload-drop-label">Drop files here</strong><br>
<span class="ots-upload-dropzone-sub">or <span class="ots-upload-browse-link">browse your computer</span></span>
</p>
</div>
</div>
{# File input lives outside the dropzone to avoid click-event loops #}
<input type="file" id="ots-upload-input" name="files[]" multiple class="ots-upload-input-hidden" />
</form>
{# Valid extensions badge #}
<div class="ots-upload-ext-info d-none" id="ots-upload-ext-info"></div>
{# Options row (update in layouts / delete old revisions) #}
<div class="ots-upload-options d-none" id="ots-upload-options"></div>
{# File list / queue #}
<div class="ots-upload-queue d-none" id="ots-upload-queue">
<div class="ots-upload-queue-header">
<span class="ots-upload-queue-title">Files</span>
<span class="ots-upload-queue-count" id="ots-upload-queue-count"></span>
</div>
<ul class="ots-upload-file-list" id="ots-upload-file-list"></ul>
</div>
</div>{# /ots-upload-tab-file #}
{# ── URL TAB ───────────────────────────────────────────────── #}
<div class="ots-upload-tab-content d-none" id="ots-upload-tab-url">
<div class="ots-upload-url-section">
<div class="ots-upload-url-icon">
<svg width="40" height="40" viewBox="0 0 40 40" fill="none">
<path d="M17 23l6-6m-3.5.5a5 5 0 017.07 0l1.42 1.42a5 5 0 010 7.07l-2.83 2.83a5 5 0 01-7.07 0" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
<path d="M23 17l-6 6m3.5-.5a5 5 0 00-7.07 0l-1.42-1.42a5 5 0 010-7.07l2.83-2.83a5 5 0 017.07 0" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
</svg>
</div>
<p class="ots-upload-url-desc">Add media from an external URL</p>
<div class="ots-upload-url-fields">
<div class="ots-upload-url-field">
<label for="ots-upload-url-input">URL</label>
<input type="url" id="ots-upload-url-input" class="form-control ots-upload-url-input"
placeholder="https://example.com/image.jpg" autocomplete="off" />
</div>
<button type="button" class="btn ots-upload-btn-start ots-upload-url-add" id="ots-upload-url-add">
<i class="fas fa-plus"></i> Add to queue
</button>
</div>
{# URL queue list #}
<div class="ots-upload-queue d-none" id="ots-upload-url-queue">
<div class="ots-upload-queue-header">
<span class="ots-upload-queue-title">URLs</span>
<span class="ots-upload-queue-count" id="ots-upload-url-queue-count"></span>
</div>
<ul class="ots-upload-file-list" id="ots-upload-url-list"></ul>
</div>
</div>
</div>{# /ots-upload-tab-url #}
</div>
{# Footer #}
<div class="modal-footer ots-upload-footer">
<button type="button" class="btn ots-upload-btn-cancel" data-dismiss="modal" id="ots-upload-btn-cancel">Cancel</button>
<button type="button" class="btn ots-upload-btn-start d-none" id="ots-upload-btn-start">
<i class="fas fa-cloud-upload-alt"></i> <span id="ots-upload-btn-start-label">Start upload</span>
</button>
<button type="button" class="btn ots-upload-btn-done d-none" id="ots-upload-btn-done">Done</button>
</div>
</div>
</div>
</div>
{# ── Upload JavaScript ──────────────────────────────────────────────────── #}
<script type="text/javascript" nonce="{{ cspNonce }}">
/**
* openUploadForm(options)
* Drop-in replacement for the core Xibo openUploadForm.
* Keeps the same options API so existing page callers (library, layout, fonts,
* player-software, dataset) work without modification.
*
* Options shape (all optional except url):
* {
* url: String POST endpoint
* title: String modal title
* initialisedBy: String an identifier for the caller
* buttons: {
* main: { label, className, callback }
* },
* templateOptions: {
* multi: Boolean allow multiple files (default true)
* trans: { addFiles, startUpload, cancelUpload, selectFolder, ... },
* upload: { maxSize, maxSizeMessage, validExt, validExtensionsMessage },
* folderSelector: Boolean,
* currentWorkingFolderId: Number,
* oldMediaId: Number when replacing a media item
* oldFolderId: Number,
* updateInAllChecked: Boolean,
* deleteOldRevisionsChecked: Boolean,
* },
* uploadDoneEvent: Function called when all uploads finish
* }
*/
window.openUploadForm = function openUploadForm(options) {
'use strict';
options = options || {};
var tOpts = options.templateOptions || {};
var trans = tOpts.trans || {};
var upload = tOpts.upload || {};
var multi = tOpts.multi !== false;
// ── References ──
var $modal = $('#ots-upload-modal');
var $title = $('#ots-upload-modal-title');
var $dropzone = $('#ots-upload-dropzone');
var $form = $('#ots-upload-form');
var $input = $('#ots-upload-input');
var $queue = $('#ots-upload-queue');
var $fileList = $('#ots-upload-file-list');
var $queueCount= $('#ots-upload-queue-count');
var $btnStart = $('#ots-upload-btn-start');
var $btnDone = $('#ots-upload-btn-done');
var $btnCancel = $('#ots-upload-btn-cancel');
var $startLabel= $('#ots-upload-btn-start-label');
var $folderRow = $('#ots-upload-folder-row');
var $sizeNotice= $('#ots-upload-size-notice');
var $extInfo = $('#ots-upload-ext-info');
var $optionsRow= $('#ots-upload-options');
var $dropLabel = $('#ots-upload-drop-label');
// ── Extra references for URL tab ──
var $tabFile = $('#ots-tab-file');
var $tabUrl = $('#ots-tab-url');
var $panelFile = $('#ots-upload-tab-file');
var $panelUrl = $('#ots-upload-tab-url');
var $urlInput = $('#ots-upload-url-input');
var $urlAddBtn = $('#ots-upload-url-add');
var $urlQueue = $('#ots-upload-url-queue');
var $urlList = $('#ots-upload-url-list');
var $urlCount = $('#ots-upload-url-queue-count');
var urlQueue = []; // { url, id, status, $el, xhr }
// ── Reset state ──
$fileList.empty();
$urlList.empty();
$queue.addClass('d-none');
$urlQueue.addClass('d-none');
$btnStart.addClass('d-none');
$btnDone.addClass('d-none');
$folderRow.addClass('d-none');
$sizeNotice.addClass('d-none');
$extInfo.addClass('d-none');
$optionsRow.addClass('d-none').empty();
$input.val('');
$urlInput.val('');
$dropzone.removeClass('ots-upload-dropzone--over ots-upload-dropzone--has-files');
// Reset to file tab
$tabFile.addClass('active');
$tabUrl.removeClass('active');
$panelFile.removeClass('d-none');
$panelUrl.addClass('d-none');
// ── Populate UI from options ──
$title.text(options.title || 'Upload');
$startLabel.text(trans.startUpload || 'Start upload');
$btnCancel.text(trans.cancelUpload || 'Cancel');
$dropLabel.text(trans.addFiles || 'Drop files here');
if (!multi) {
$input.removeAttr('multiple');
} else {
$input.attr('multiple', 'multiple');
}
// Max file size notice
if (upload.maxSizeMessage) {
$sizeNotice.text(upload.maxSizeMessage).removeClass('d-none');
}
// Valid extensions
if (upload.validExt) {
var extList = upload.validExt.replace(/\|/g, ', ');
var extMsg = upload.validExtensionsMessage || ('Allowed: ' + extList);
$extInfo.text(extMsg).removeClass('d-none');
}
// Folder selector
if (tOpts.folderSelector) {
$folderRow.removeClass('d-none');
$('#ots-upload-folder-label').text((trans.selectedFolder || 'Current Folder:'));
$('#ots-upload-folder-text').text(trans.selectFolder || 'Select Folder');
$('#ots-upload-folder-btn').attr('title', trans.selectFolderTitle || 'Change folder');
// Wire folder-selector button using the CMS's built-in folder-tree modal
// (templates['folder-tree'], initJsTreeAjax — provided by the Xibo core)
$('#ots-upload-folder-btn').off('click').on('click', function() {
var modalId = 'ots-upload-folder-tree-modal';
var containerId = 'ots-upload-folder-form-tree';
var $ftModal = $('#' + modalId);
// ── First open: build the modal from the Handlebars template ──
if ($ftModal.length === 0 && typeof templates !== 'undefined' && templates['folder-tree']) {
var folderTreeTpl = templates['folder-tree'];
var treeConfig = {
container: containerId,
modal: modalId
};
if (typeof translations !== 'undefined' && translations.folderTree) {
treeConfig.trans = translations.folderTree;
}
$('body').append(folderTreeTpl(treeConfig));
$ftModal = $('#' + modalId);
// Inject OK / Cancel footer
var $footer = $ftModal.find('.modal-footer');
if ($footer.length === 0) {
$footer = $('<div class="modal-footer"></div>');
$ftModal.find('.modal-content').append($footer);
}
$footer.empty().append(
'<button type="button" class="btn btn-sm ots-upload-btn-cancel" data-dismiss="modal">Cancel</button>' +
'<button type="button" class="btn btn-sm ots-upload-btn-start" id="ots-folder-confirm-btn">' +
'<i class="fas fa-check"></i> OK' +
'</button>'
);
// Configure as static backdrop once
$ftModal.modal({ backdrop: 'static', keyboard: true, show: false });
// Fix stacked-modal body class when this modal closes
$ftModal.on('hidden.bs.modal', function() {
if ($('.modal:visible').length) {
$(document.body).addClass('modal-open');
}
});
}
if ($ftModal.length === 0) {
console.warn('Folder tree template not available');
return;
}
// ── Every open: reset pending selection and re-init jstree ──
var pendingFolderId = tOpts.currentWorkingFolderId || null;
var pendingFolderName = null;
// Destroy previous jstree instance so it re-initialises cleanly
var $treeContainer = $ftModal.find('#' + containerId);
if ($treeContainer.jstree && $treeContainer.jstree(true)) {
try { $treeContainer.jstree('destroy'); } catch(e) {}
}
// Initialise jstree
if (typeof initJsTreeAjax === 'function') {
initJsTreeAjax($treeContainer, 'ots-upload-form', true, 600);
}
// Show the modal (works on first and subsequent opens)
$ftModal.modal('show');
// Bind selection handler after the modal is visible + jstree auto-select settles
$ftModal.off('shown.bs.modal.otsUpload').on('shown.bs.modal.otsUpload', function() {
setTimeout(function() {
$treeContainer.off('select_node.jstree.otsUpload')
.on('select_node.jstree.otsUpload', function(e, data) {
if (data && data.node) {
pendingFolderId = data.node.id;
pendingFolderName = data.node.text || data.node.id;
}
});
}, 500);
});
// OK button — apply selection and close
$ftModal.find('#ots-folder-confirm-btn').off('click').on('click', function() {
if (pendingFolderId) {
tOpts.currentWorkingFolderId = pendingFolderId;
$('#ots-upload-folder-text').text(pendingFolderName || pendingFolderId);
}
$ftModal.modal('hide');
});
});
}
// Done button
var mainBtn = (options.buttons && options.buttons.main) || {};
$btnDone.text(mainBtn.label || 'Done');
if (mainBtn.className) {
$btnDone.attr('class', 'btn ots-upload-btn-done d-none ' + mainBtn.className);
}
// ── Internal state ──
var fileQueue = []; // { file, id, status, $el, xhr }
var nextId = 0;
var uploading = false;
var uploadCount = 0;
var successCount= 0;
// ── Helper: human-readable size ──
function humanSize(bytes) {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / 1048576).toFixed(1) + ' MB';
}
// ── Helper: valid extension check ──
function isExtAllowed(filename) {
if (!upload.validExt) return true;
var ext = filename.split('.').pop().toLowerCase();
var allowed = upload.validExt.toLowerCase().split('|');
return allowed.indexOf(ext) !== -1;
}
// ── Helper: generate preview (images only) ──
function generatePreview(file, $thumb) {
if (file.type && file.type.indexOf('image/') === 0 && file.size < 10 * 1048576) {
var reader = new FileReader();
reader.onload = function(e) {
$thumb.css('background-image', 'url(' + e.target.result + ')').addClass('has-preview');
};
reader.readAsDataURL(file);
} else {
// Icon based on type
var icon = 'fa-file';
if (file.type && file.type.indexOf('video/') === 0) icon = 'fa-file-video';
else if (file.type && file.type.indexOf('audio/') === 0) icon = 'fa-file-audio';
else if (file.type && file.type.indexOf('application/pdf') === 0) icon = 'fa-file-pdf';
else if (file.name && /\.(xlsx?|csv)$/i.test(file.name)) icon = 'fa-file-excel';
$thumb.html('<i class="fas ' + icon + '"></i>');
}
}
// ── Add files to queue ──
function addFiles(files) {
for (var i = 0; i < files.length; i++) {
var file = files[i];
// Multi check
if (!multi && fileQueue.length >= 1) {
// Replace existing file
fileQueue = [];
$fileList.empty();
}
var id = nextId++;
var extOk = isExtAllowed(file.name);
var sizeOk = !upload.maxSize || file.size <= upload.maxSize;
var statusClass = '';
var statusText = humanSize(file.size);
if (!extOk) { statusClass = 'ots-upload-file--error'; statusText = 'Invalid file type'; }
else if (!sizeOk) { statusClass = 'ots-upload-file--error'; statusText = 'File too large'; }
var $el = $(
'<li class="ots-upload-file-item ' + statusClass + '" data-id="' + id + '">' +
'<div class="ots-upload-file-thumb"></div>' +
'<div class="ots-upload-file-info">' +
'<span class="ots-upload-file-name">' + $('<span>').text(file.name).html() + '</span>' +
'<span class="ots-upload-file-meta">' + statusText + '</span>' +
'<div class="ots-upload-file-progress"><div class="ots-upload-file-progress-bar"></div></div>' +
'</div>' +
'<button type="button" class="ots-upload-file-remove" aria-label="Remove" title="Remove">' +
'<i class="fas fa-times"></i>' +
'</button>' +
'</li>'
);
generatePreview(file, $el.find('.ots-upload-file-thumb'));
$el.find('.ots-upload-file-remove').on('click', (function(fileId) {
return function() { removeFile(fileId); };
})(id));
$fileList.append($el);
fileQueue.push({
file: file,
id: id,
status: (extOk && sizeOk) ? 'pending' : 'error',
$el: $el,
xhr: null
});
}
updateQueueUI();
}
// ── Remove file ──
function removeFile(id) {
fileQueue = fileQueue.filter(function(f) {
if (f.id === id) {
f.$el.slideUp(200, function() { $(this).remove(); });
if (f.xhr) { try { f.xhr.abort(); } catch(e){} }
return false;
}
return true;
});
updateQueueUI();
}
// ── Queue UI update ──
function updateQueueUI() {
var validFiles = fileQueue.filter(function(f) { return f.status === 'pending'; });
var total = fileQueue.length;
$queueCount.text(total + ' file' + (total !== 1 ? 's' : ''));
if (total > 0) {
$queue.removeClass('d-none');
$dropzone.addClass('ots-upload-dropzone--has-files');
} else {
$queue.addClass('d-none');
$dropzone.removeClass('ots-upload-dropzone--has-files');
}
// Show start button only when there are valid pending files and not already uploading
if (validFiles.length > 0 && !uploading) {
$btnStart.removeClass('d-none');
} else if (!uploading) {
$btnStart.addClass('d-none');
}
}
// ── Upload all pending items (files + URLs) ──
function startUpload() {
var filePending = fileQueue.filter(function(f) { return f.status === 'pending'; });
var urlPending = urlQueue.filter(function(f) { return f.status === 'pending'; });
var allPending = filePending.concat(urlPending);
if (allPending.length === 0) return;
uploading = true;
uploadCount = allPending.length;
successCount = 0;
$btnStart.addClass('d-none');
$btnCancel.text(trans.cancelUpload || 'Cancel');
// Upload sequentially
var idx = 0;
function uploadNext() {
if (idx >= allPending.length) {
uploading = false;
onAllDone();
return;
}
var item = allPending[idx++];
if (item.file) {
uploadSingle(item, uploadNext);
} else if (item.url) {
uploadUrlItem(item, uploadNext);
} else {
uploadNext();
}
}
uploadNext();
}
// ── Upload a single file ──
function uploadSingle(item, callback) {
item.status = 'uploading';
item.$el.addClass('ots-upload-file--uploading');
var formData = new FormData();
formData.append('files[]', item.file, item.file.name);
// Standard Xibo hidden fields
if (tOpts.currentWorkingFolderId) formData.append('folderId', tOpts.currentWorkingFolderId);
if (tOpts.oldMediaId) formData.append('oldMediaId', tOpts.oldMediaId);
if (tOpts.oldFolderId) formData.append('oldFolderId', tOpts.oldFolderId);
// Checkboxes
$optionsRow.find('input[type="checkbox"]').each(function() {
formData.append($(this).attr('name'), $(this).is(':checked') ? '1' : '0');
});
var $bar = item.$el.find('.ots-upload-file-progress-bar');
var $meta = item.$el.find('.ots-upload-file-meta');
item.xhr = $.ajax({
url: options.url,
type: 'POST',
data: formData,
processData: false,
contentType: false,
timeout: 120000,
xhr: function() {
var xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
var pct = Math.round((e.loaded / e.total) * 100);
$bar.css('width', pct + '%');
$meta.text(pct + '%');
}
});
return xhr;
},
success: function(response) {
item.status = 'done';
item.$el.removeClass('ots-upload-file--uploading').addClass('ots-upload-file--done');
$bar.css('width', '100%');
$meta.text('Complete');
successCount++;
if (typeof options.uploadDoneEvent === 'function') {
options.uploadDoneEvent(item.file, response);
}
callback();
},
error: function(xhr) {
item.status = 'error';
item.$el.removeClass('ots-upload-file--uploading').addClass('ots-upload-file--error');
var msg = 'Upload failed';
try { msg = JSON.parse(xhr.responseText).message || msg; } catch(e){}
$meta.text(msg);
$bar.css('width', '0%');
callback();
}
});
}
// ── All uploads finished ──
function onAllDone() {
$btnDone.removeClass('d-none');
$btnStart.addClass('d-none');
$queueCount.text(successCount + '/' + uploadCount + ' uploaded');
}
// ── Drag & drop ──
$dropzone.off('.otsUpload').on({
'dragenter.otsUpload dragover.otsUpload': function(e) {
e.preventDefault();
e.stopPropagation();
$dropzone.addClass('ots-upload-dropzone--over');
},
'dragleave.otsUpload': function(e) {
e.preventDefault();
e.stopPropagation();
$dropzone.removeClass('ots-upload-dropzone--over');
},
'drop.otsUpload': function(e) {
e.preventDefault();
e.stopPropagation();
$dropzone.removeClass('ots-upload-dropzone--over');
var dt = e.originalEvent.dataTransfer;
if (dt && dt.files && dt.files.length) {
addFiles(dt.files);
}
}
});
// Click to browse — use native .click() on the raw DOM element;
// jQuery's .trigger('click') does NOT open the file picker in most browsers.
$dropzone.off('click.otsUpload').on('click.otsUpload', function(e) {
// Don't trigger if clicking on the remove button inside the queue
if ($(e.target).closest('.ots-upload-file-remove').length) return;
$input[0].click();
});
// Keyboard accessibility on dropzone
$dropzone.off('keydown.otsUpload').on('keydown.otsUpload', function(e) {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
$input[0].click();
}
});
// File input change
$input.off('change.otsUpload').on('change.otsUpload', function() {
if (this.files && this.files.length) {
addFiles(this.files);
// Reset so the same file can be re-selected
this.value = '';
}
});
// Start upload button
$btnStart.off('click.otsUpload').on('click.otsUpload', function() {
startUpload();
});
// Done button
$btnDone.off('click.otsUpload').on('click.otsUpload', function() {
if (mainBtn.callback) {
mainBtn.callback();
}
$modal.modal('hide');
});
// Clean up on modal close
$modal.off('hidden.bs.modal.otsUpload').on('hidden.bs.modal.otsUpload', function() {
// Abort any in-progress uploads
fileQueue.forEach(function(f) {
if (f.xhr) { try { f.xhr.abort(); } catch(e){} }
});
fileQueue = [];
$fileList.empty();
uploading = false;
});
// ── Tab switching ──
$tabFile.off('click.otsUpload').on('click.otsUpload', function() {
$tabFile.addClass('active');
$tabUrl.removeClass('active');
$panelFile.removeClass('d-none');
$panelUrl.addClass('d-none');
});
$tabUrl.off('click.otsUpload').on('click.otsUpload', function() {
$tabUrl.addClass('active');
$tabFile.removeClass('active');
$panelUrl.removeClass('d-none');
$panelFile.addClass('d-none');
});
// ── URL: add to queue ──
function addUrlToQueue(url) {
if (!url || !url.trim()) return;
url = url.trim();
var id = nextId++;
var displayName = url.length > 60 ? url.substring(0, 57) + '...' : url;
var $el = $(
'<li class="ots-upload-file-item" data-id="' + id + '">' +
'<div class="ots-upload-file-thumb"><i class="fas fa-link"></i></div>' +
'<div class="ots-upload-file-info">' +
'<span class="ots-upload-file-name">' + $('<span>').text(displayName).html() + '</span>' +
'<span class="ots-upload-file-meta">Ready</span>' +
'<div class="ots-upload-file-progress"><div class="ots-upload-file-progress-bar"></div></div>' +
'</div>' +
'<button type="button" class="ots-upload-file-remove" aria-label="Remove" title="Remove">' +
'<i class="fas fa-times"></i>' +
'</button>' +
'</li>'
);
$el.find('.ots-upload-file-remove').on('click', (function(fileId) {
return function() { removeUrlItem(fileId); };
})(id));
$urlList.append($el);
urlQueue.push({ url: url, id: id, status: 'pending', $el: $el, xhr: null });
updateUrlQueueUI();
}
function removeUrlItem(id) {
urlQueue = urlQueue.filter(function(f) {
if (f.id === id) {
f.$el.slideUp(200, function() { $(this).remove(); });
if (f.xhr) { try { f.xhr.abort(); } catch(e){} }
return false;
}
return true;
});
updateUrlQueueUI();
}
function updateUrlQueueUI() {
var pending = urlQueue.filter(function(f) { return f.status === 'pending'; });
var total = urlQueue.length;
$urlCount.text(total + ' URL' + (total !== 1 ? 's' : ''));
if (total > 0) {
$urlQueue.removeClass('d-none');
} else {
$urlQueue.addClass('d-none');
}
if (pending.length > 0 && !uploading) {
$btnStart.removeClass('d-none');
} else if (!uploading && fileQueue.filter(function(f) { return f.status === 'pending'; }).length === 0) {
$btnStart.addClass('d-none');
}
}
$urlAddBtn.off('click.otsUpload').on('click.otsUpload', function() {
addUrlToQueue($urlInput.val());
$urlInput.val('').focus();
});
// Allow Enter key in URL input to add
$urlInput.off('keydown.otsUpload').on('keydown.otsUpload', function(e) {
if (e.key === 'Enter') {
e.preventDefault();
addUrlToQueue($urlInput.val());
$urlInput.val('').focus();
}
});
// ── Upload a single URL item ──
function uploadUrlItem(item, callback) {
item.status = 'uploading';
item.$el.addClass('ots-upload-file--uploading');
var $bar = item.$el.find('.ots-upload-file-progress-bar');
var $meta = item.$el.find('.ots-upload-file-meta');
$bar.css('width', '50%');
$meta.text('Downloading...');
var postData = { url: item.url };
if (tOpts.currentWorkingFolderId) postData.folderId = tOpts.currentWorkingFolderId;
item.xhr = $.ajax({
url: options.url,
type: 'POST',
data: postData,
timeout: 120000,
success: function(response) {
item.status = 'done';
item.$el.removeClass('ots-upload-file--uploading').addClass('ots-upload-file--done');
$bar.css('width', '100%');
$meta.text('Complete');
successCount++;
if (typeof options.uploadDoneEvent === 'function') {
options.uploadDoneEvent(null, response);
}
callback();
},
error: function(xhr) {
item.status = 'error';
item.$el.removeClass('ots-upload-file--uploading').addClass('ots-upload-file--error');
var msg = 'Upload failed';
try { msg = JSON.parse(xhr.responseText).message || msg; } catch(e){}
$meta.text(msg);
$bar.css('width', '0%');
callback();
}
});
}
// ── Clean up URL queue on modal close ──
$modal.off('hidden.bs.modal.otsUploadUrl').on('hidden.bs.modal.otsUploadUrl', function() {
urlQueue.forEach(function(f) {
if (f.xhr) { try { f.xhr.abort(); } catch(e){} }
});
urlQueue = [];
$urlList.empty();
});
// ── Show modal ──
$modal.modal({ backdrop: 'static', keyboard: true });
};
</script>

View File

@@ -1,327 +0,0 @@
{% macro disabled(name, title, value, helpText, groupClass) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}">{{ title }}</label>
<input readonly class="form-control" value="{{ value }}"></input>
</div>
{% endmacro %}
{% macro hidden(name, value) %}
<input name="{{ name }}" type="hidden" id="{{ name }}" value="{{ value }}" />
{% endmacro %}
{% macro raw(text, groupClass) %}
<div class="{{ groupClass }}">
{{ text|raw }}
</div>
{% endmacro %}
{% macro message(message, groupClass, messageStyleClass) %}
<div class="{% if messageStyleClass %}{{messageStyleClass}}{% endif %} mr-1 {{ groupClass }}">
<span>{{ message }}</span>
</div>
{% endmacro %}
{% macro alert(message, alertType, groupClass) %}
<div class="row">
<div class="mr-3 alert alert-{% if alertType %}{{alertType}}{% else %}primary{% endif %} {{ groupClass }}" role="alert">{{ message }}</div>
</div>
{% endmacro %}
{% macro button(title, type, link, groupClass) %}
<div class="form-group {{ groupClass }}">
{% if type == "link" %}
<a class="btn btn-white xibo-inline-btn mr-1 ml-0" href="{{ link }}">{{ title }}</a>
{% else %}
<button class="btn btn-white xibo-inline-btn mr-1 ml-0" type="{{ type }}">{{ title }}</button>
{% endif %}
</div>
{% endmacro %}
{% macro input(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro inputWithTags(name, title, value, helpText, groupClass, validation, accessKey, exactTag, exactTagTitle, logicalOperatorTitle, autoCompleteEnabled = 1) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
{% if exactTag %}
<div class="input-group input-group-tags-exact">
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" data-role="tagsInputInline" {% if autoCompleteEnabled == 1 %} data-auto-complete-url="{{ url_for('tag.search') }}?allTags=1" {% endif %} {{ validation }} />
<div class="input-group-append input-group-addon">
<div class="input-group-text">
<input title="{{ exactTagTitle }}" type="checkbox" id="{{ exactTag }}" name="{{ exactTag }}">
</div>
<select class="custom-select" id="logicalOperator" name="logicalOperator" title="{{ logicalOperatorTitle }}" style="min-width:auto!important">
<option value="OR" selected>OR</option>
<option value="AND">AND</option>
</select>
</div>
</div>
{% else %}
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" data-role="tagsInputInline" {% if autoCompleteEnabled == 1 %} data-auto-complete-url="{{ url_for('tag.search') }}?allTags=1" {% endif %} {{ validation }} />
{% endif %}
</div>
{% endmacro %}
{% macro number(name, title, value, helpText, groupClass, validation, accessKey, maxNumber, minNumber) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" {% if maxNumber %}max="{{maxNumber}}" {% endif %}{% if minNumber %}min="{{minNumber}}" {% endif %}type="number" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro email(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" type="email" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro password(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" for="{{ name }}" title="{{ helpText }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control" name="{{ name }}" type="password" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro checkbox(name, title, value, groupClass, accessKey) %}
<div class="form-group ml-2 mr-3 mb-1 {{ groupClass }}">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="{{ name }}" name="{{ name }}" {% if value == 1 %}checked{% endif %}>
<label class="form-check-label" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
</div>
</div>
{% endmacro %}
{% macro radio(name, id, title, value, helpText, groupClass, accessKey, setValue) %}
<div class="form-group ml-2 mr-3 mb-1 {{ groupClass }}">
<div class="form-check">
<input class="form-check-input" type="radio" id="{{ id }}" name="{{ name }}" value="{{ setValue }}" {% if value == setValue %}checked{% endif %}>
<label class="form-check-label" for="{{ name }}" title="{{ helpText }}" accesskey="{{ accessKey }}">{{ title }}</label>
</div>
</div>
{% endmacro %}
{% macro dropdown(name, type, title, value, options, optionId, optionValue, helpText, groupClass, validation, accessKey, callBack, dataAttributes, optionGroups) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" for="{{ name }}" title="{{ helpText }}" accesskey="{{ accessKey }}">{{ title }}</label>
<select class="form-control" {% if type == "dropdownmulti" %}multiple{% endif %} name="{{ name }}" id="{{ name }}" {{ callBack }}
{% if type == "dropdownmulti" %}
data-allow-clear="true"
data-placeholder--id=null
data-placeholder--value=""
{% endif %}
{% if dataAttributes|length > 0 %}
{% for attribute in dataAttributes %}
{{ attribute.name }}="{{ attribute.value }}"
{% endfor %}
{% endif %}>
{% set hasGroups = optionGroups|length > 0 %}
{% if not hasGroups %}
{% set optionGroups = {label: "General"} %}
{% endif %}
{% for group in optionGroups %}
{% if hasGroups %}
<optgroup label="{{ group.label }}">
{% set tempOptions = attribute(options, group.id) %}
{% else %}
{% set tempOptions = options %}
{% endif %}
{% for option in tempOptions %}
{% set itemOptionId = attribute(option, optionId) %}
{% set itemOptionValue = attribute(option, optionValue) %}
{% if type == "dropdownmulti" %}
{% set selected = (itemOptionId in value) %}
{% else %}
{% set selected = (itemOptionId == value) %}
{% endif %}
<option value="{{ itemOptionId }}" {% if selected %}selected{% endif %}>{{ itemOptionValue }}</option>
{% endfor %}
{% if hasGroups %}
</optgroup>
{% endif %}
{% endfor %}
</select>
</div>
{% endmacro %}
{% macro permissions(name, options) %}
<table class="table table-bordered">
<tr>
<th>{% trans "Group" %}</th>
<th>{% trans "View" %}</th>
<th>{% trans "Edit" %}</th>
<th>{% trans "Delete" %}</th>
</tr>
{% for item in options %}
<tr>
<td>{{ name }}</td>
<td><input type="checkbox" name="{{ name }}" value="{{ value_view }}" {{ value_view_checked }}></td>
<td><input type="checkbox" name="{{ name }}" value="{{ value_edit }}" {{ value_edit_checked }}></td>
<td><input type="checkbox" name="{{ name }}" value="{{ value_del }}" {{ value_del_checked }}></td>
</tr>
{% endfor %}
</table>
{% endmacro %}
{% macro date(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<div class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></div>
<input class="form-control dateControl date" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro dateMonth(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<span class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></span>
<input class="form-control dateControl month" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro dateTime(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ linkedName }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<span class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></span>
<input class="form-control dateControl dateTime" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro time(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1 {% if title == '' %}d-none{% endif %}" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<div class="input-group">
<span class="input-group-prepend input-group-text date-open-button" role="button"><i class="fa fa-calendar"></i></span>
<input class="form-control dateControl time" type="text" {{ validation }} name="{{ name }}" id="{{ name }}" value="{{ value }}" />
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none" role="button"><i class="fa fa-times"></i></span>
</div>
</div>
{% endmacro %}
{% macro switch(name, title, value, labelWidth, switchSize, onText, offText, groupClass, accessKey, disabled) %}
<div class="form-group {{ groupClass }}">
<div class="checkbox">
<input type="checkbox" class="bootstrap-switch-target" id="{{ name }}" name="{{ name }}" accesskey="{{ accessKey }}"
{% if value == 1 %}checked{% endif %}
{% if disabled == 1 %}disabled{% endif %}
data-label-text="{{ title }}"
{% if onText not in [null, undefined, ""] %} data-on-text="{{ onText }}"{% endif %}
{% if offText not in [null, undefined, ""] %} data-off-text="{{ offText }}"{% endif %}
{% if switchSize not in [null, undefined, ""] %}data-size="{{ switchSize }}"{% else %}data-size="small"{% endif %}
{% if labelWidth not in [null, undefined, ""] %} data-label-width="{{ labelWidth }}"{% endif %}
>
</div>
</div>
{% endmacro %}
{% macro color(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">{{ title }}</label>
<input class="form-control XiboColorPicker" name="{{ name }}" type="text" id="{{ name }}" value="{{ value }}" {{ validation }} />
</div>
{% endmacro %}
{% macro inputNameGrid(name, title, groupClass, useRegexName, logicalOperatorName) %}
<div class="form-group mr-1 mb-1 {{ groupClass }}">
<label class="control-label mr-1" title="" for="{{ name }}" accesskey="">{{ title }}</label>
<div>
<div class="input-group">
<input class="form-control" name="{{ name }}" type="text" id="{{ name }}" value="">
<div class="input-group-append input-group-addon">
<div class="input-group-text">
<input title="{% trans "Use Regex?" %}" type="checkbox" {% if useRegexName %} id="{{ useRegexName }}" name="{{ useRegexName }}" {% else %} id="useRegexForName" name="useRegexForName"{% endif %}>
</div>
<select class="custom-select" {% if logicalOperatorName %} id="{{ logicalOperatorName }}" name="{{ logicalOperatorName }}" {% else %} id="logicalOperatorName" name="logicalOperatorName"{% endif %}
title="{% trans "When filtering by multiple names, which logical operator should be used?" %}" style="min-width:auto!important">
<option value="OR" selected>OR</option>
<option value="AND">AND</option>
</select>
</div>
</div>
</div>
</div>
{% endmacro %}
{% macro dateRangeFilter(name, title, value, helpText, groupClass, validation, accessKey) %}
<div class="form-group mr-1 mb-1 d-flex flex-row {{ groupClass }}">
{% set today = now | date_modify('today') | date("Y-m-d H:i:s") %}
<div class="form-group mr-1">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">
{{ title }}
</label>
<div class="d-inline-flex">
<select class="form-control XiboDateRangeFilter" name="{{ name }}" id="{{ name }}">
<option value="" >{% trans "Select a range" %}</option>
<option value="today" selected>{% trans "Today" %}</option>
<option value="yesterday">{% trans "Yesterday" %}</option>
<option value="thisweek">{% trans "This Week" %}</option>
<option value="thismonth">{% trans "This Month" %}</option>
<option value="thisyear">{% trans "This Year" %}</option>
<option value="lastweek">{% trans "Last Week" %}</option>
<option value="lastmonth">{% trans "Last Month" %}</option>
<option value="lastyear">{% trans "Last Year" %}</option>
</select>
</div>
</div>
<div class="form-group hidden mr-1 {{ 'rangeFilterInput_' ~ name }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">
{% trans "From Date" %}
</label>
<div class="input-group">
<div class="input-group-prepend input-group-text date-open-button" role="button">
<i class="fa fa-calendar"></i>
</div>
<input class="form-control dateControl date rangeInput"
type="text" name="fromDt" id="{{ 'fromDt_' ~ name }}"
value="{{ today }}"
/>
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none"
role="button"
>
<i class="fa fa-times"></i>
</span>
</div>
</div>
<div class="form-group hidden {{ 'rangeFilterInput_' ~ name }}">
<label class="control-label mr-1" title="{{ helpText }}" for="{{ name }}" accesskey="{{ accessKey }}">
{% trans "To Date" %}
</label>
<div class="input-group">
<div class="input-group-prepend input-group-text date-open-button" role="button">
<i class="fa fa-calendar"></i>
</div>
<input class="form-control dateControl date rangeInput"
type="text" name="toDt" id="{{ 'toDt_' ~ name }}"
value="{{ today | date_modify('+1 day -1 second') | date("Y-m-d H:i:s") }}"
/>
<span class="input-group-append input-group-addon input-group-text date-clear-button d-none"
role="button"
>
<i class="fa fa-times"></i>
</span>
</div>
</div>
</div>
{% endmacro %}

View File

@@ -62,6 +62,13 @@
</script>
<style nonce="{{ cspNonce }}">
/* ── Ensure editor pop-ups appear above our fixed action bars ───────────── */
/* Our #ots-editor-bar sits at z-index 1300. Bootstrap modals default to
backdrop:1040 / modal:1050, so they render behind it. Raise them to
1302/1303 so the Checkout / welcome dialogs always appear on top. */
.modal-backdrop { z-index: 1302 !important; }
.modal { z-index: 1303 !important; }
/* ── Embed mode styles ──────────────────────────────────── */
/* Hide Back/Exit button area */
@@ -74,11 +81,6 @@
display: none !important;
}
/* Hide floating page actions (notification bell, user menu) */
.ots-embed-mode .ots-page-actions {
display: none !important;
}
/* Remove content wrapper padding/margins for full-bleed editor */
.ots-embed-mode #content-wrapper {
padding: 0 !important;
@@ -126,11 +128,6 @@
display: none !important;
}
/* Hide floating page actions (notification bell, user menu) */
.ots-deeplink-mode .ots-page-actions {
display: none !important;
}
/* Remove content wrapper padding/margins for full-bleed editor */
.ots-deeplink-mode #content-wrapper {
padding: 0 !important;
@@ -156,16 +153,81 @@
min-height: 100vh;
}
/* Push body content down to clear the fixed OTS back bar (44px) */
.ots-deeplink-mode body {
padding-top: 44px !important;
/* ── Editor positioning fix ─────────────────────────────────────────────
Xibo's editor internal structure (from layout-editor.hbs + layout-editor.scss):
#layout-editor
.editor-top-bar ← always min-height ~40px in normal flow;
its children are position:fixed top:0 (covered by our bar)
.container-designer { height: calc(100vh - 50px) }
Keeping #layout-editor in normal flow and hiding .editor-top-bar removes
the 40px gap. We then push .container-designer down with margin-top and
resize it so it fills exactly from below our bar to the viewport bottom.
─────────────────────────────────────────────────────────────────────── */
/* Restore #layout-editor to normal document flow */
#layout-editor {
position: static !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
width: auto !important;
height: auto !important;
/* Preserve Xibo's horizontal negative margins (Bootstrap column gutter) */
margin: 0 -15px -15px -15px !important;
}
/* ── OTS branded back bar ──────────────────────────────── */
/* Xibo's .editor-top-bar inner items use position:fixed top:0 so they
already render behind our #ots-editor-bar (z-index 1300). Hiding the
container removes the dead 40px normal-flow space it leaves behind. */
.editor-top-bar {
display: none !important;
}
/* Hidden by default; revealed only in deep-link mode */
#ots-deeplink-backbar {
display: none;
/* Reposition the playlist-editor "Back to Layout" button below our bar
instead of hiding it — it's shown by lD.openPlaylistEditor() */
.back-button-playlist {
top: 50px !important;
z-index: 1290 !important;
}
/* Push the canvas below our 44px bar and fill the remaining viewport height */
body.editor-opened .container-designer {
margin-top: 44px !important;
height: calc(100vh - 44px) !important;
}
/* Loading spinner while editor data loads — same offset */
body.editor-opened #layout-editor .loading-container {
margin-top: 44px !important;
height: calc(100vh - 44px) !important;
}
/* Embed mode: no bar — canvas fills the full viewport */
.ots-embed-mode .container-designer,
.ots-embed-mode #layout-editor .loading-container {
margin-top: 0 !important;
height: 100vh !important;
}
/* Remove body-level padding (harmless safety reset) */
body {
padding-top: 0 !important;
}
/* Ensure no stray padding above the editor from page-wrapper */
#page-wrapper,
#content-wrapper {
padding-top: 0 !important;
}
/* ── OTS combined editor bar ──────────────────────────── */
/* Single fixed bar: back button + layout name + action buttons */
#ots-editor-bar {
display: flex;
position: fixed;
top: 0;
left: 0;
@@ -176,14 +238,16 @@
z-index: 1300;
align-items: center;
padding: 0 12px;
gap: 8px;
box-sizing: border-box;
}
.ots-deeplink-mode #ots-deeplink-backbar {
display: flex;
/* Hide bar inside an iframe embed */
.ots-embed-mode #ots-editor-bar {
display: none;
}
#ots-deeplink-back {
#ots-bar-back {
display: inline-flex;
align-items: center;
gap: 6px;
@@ -197,97 +261,305 @@
border-radius: 4px;
line-height: 1;
transition: background 0.15s;
flex-shrink: 0;
}
#ots-deeplink-back:hover {
#ots-bar-back:hover {
background: rgba(232, 120, 0, 0.12);
}
#ots-deeplink-back:focus-visible {
#ots-bar-back:focus-visible {
outline: 2px solid #e87800;
outline-offset: 2px;
}
#ots-deeplink-back svg {
#ots-bar-back svg {
flex-shrink: 0;
}
.ots-deeplink-wordmark {
margin-left: auto;
color: #64748b;
font-size: 12px;
/* Vertical divider between back button and layout name */
#ots-bar-divider {
width: 1px;
height: 20px;
background: #334155;
flex-shrink: 0;
}
/* ── Hide stock Xibo header bar on the layout designer page ── */
.row.header.header-side {
display: none !important;
}
/* ── Remove sidebar offset so editor fills the full viewport ── */
#page-wrapper,
#content-wrapper {
margin-left: 0 !important;
padding-left: 0 !important;
width: 100% !important;
max-width: 100% !important;
}
.page-content,
.page-content > .row,
.page-content > .row > .col-sm-12 {
padding: 0 !important;
margin: 0 !important;
}
/* Layout name / status label */
#ots-bar-label {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 4px;
}
.ots-bar-status {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
margin-left: 8px;
vertical-align: middle;
background: rgba(232, 120, 0, 0.15);
color: #e87800;
}
.ots-bar-status.ots-status-published {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
/* Shared button base */
.ots-editor-bar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 12px;
border-radius: 4px;
font-size: 13px;
font-weight: 500;
letter-spacing: 0.04em;
cursor: pointer;
border: 1px solid transparent;
line-height: 1;
background: none;
font-family: inherit;
transition: background 0.15s, opacity 0.15s, border-color 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
/* Save — filled orange */
#ots-editor-save-btn {
background: #e87800;
color: #fff;
border-color: #e87800;
}
#ots-editor-save-btn:hover {
background: #d16b00;
border-color: #d16b00;
}
/* Brief green flash after a successful save AJAX round-trip */
#ots-editor-save-btn.ots-btn-saved {
background: #16a34a !important;
border-color: #16a34a !important;
transition: none;
}
/* Publish — orange outline */
#ots-editor-publish-btn {
color: #e87800;
border-color: #e87800;
}
#ots-editor-publish-btn:hover {
background: rgba(232, 120, 0, 0.1);
}
/* Checkout — muted outline */
#ots-editor-checkout-btn {
color: #94a3b8;
border-color: #334155;
}
#ots-editor-checkout-btn:hover {
background: rgba(148, 163, 184, 0.1);
border-color: #64748b;
color: #cbd5e1;
}
/* Draft state: show Save + Publish; hide Checkout */
#ots-editor-bar .ots-draft-only { display: inline-flex; }
#ots-editor-bar .ots-readonly-only { display: none; }
/* Read-only/published state: hide Save + Publish; show Checkout */
#ots-editor-bar.ots-state-readonly .ots-draft-only { display: none !important; }
#ots-editor-bar.ots-state-readonly .ots-readonly-only { display: inline-flex !important; }
.ots-editor-bar-btn:disabled,
.ots-editor-bar-btn.ots-btn-loading {
opacity: 0.45;
cursor: default;
pointer-events: none;
user-select: none;
}
.ots-editor-bar-btn:focus-visible {
outline: 2px solid #e87800;
outline-offset: 2px;
}
/* ── Interactive mode toggle ──────────────────────── */
#ots-editor-interactive-btn {
color: #94a3b8;
border-color: #334155;
}
#ots-editor-interactive-btn:hover {
background: rgba(148, 163, 184, 0.1);
border-color: #64748b;
color: #cbd5e1;
}
/* Lit up when interactive mode is active */
#ots-editor-interactive-btn.ots-btn-active {
background: rgba(232, 120, 0, 0.15);
border-color: #e87800;
color: #e87800;
}
/* Not useful in embed mode */
.ots-embed-mode #ots-editor-interactive-btn { display: none !important; }
/* Secondary divider between interactive toggle and primary action buttons */
#ots-bar-actions-divider {
width: 1px;
height: 20px;
background: #334155;
flex-shrink: 0;
}
/* ── Discard — destructive/red outline, draft-only ── */
#ots-editor-discard-btn {
color: #f87171;
border-color: #7f1d1d;
}
#ots-editor-discard-btn:hover {
background: rgba(248, 113, 113, 0.1);
border-color: #f87171;
}
/* ── Schedule — muted outline, readonly-only ─────── */
#ots-editor-schedule-btn {
color: #94a3b8;
border-color: #334155;
}
#ots-editor-schedule-btn:hover {
background: rgba(148, 163, 184, 0.1);
border-color: #64748b;
color: #cbd5e1;
}
/* ── Unlock — amber, hidden until layout is locked ── */
#ots-editor-unlock-btn {
display: none;
color: #fbbf24;
border-color: #78350f;
}
#ots-editor-unlock-btn:hover {
background: rgba(251, 191, 36, 0.1);
border-color: #fbbf24;
}
/* .ots-bar-locked is added via JS when lD detects locked-for-user */
#ots-editor-bar.ots-bar-locked #ots-editor-unlock-btn {
display: inline-flex;
}
</style>
{% endblock %}
{% block pageContent %}
{# Deep-link mode: branded back bar (position:fixed — shown only when ots-deeplink-mode is active) #}
<div id="ots-deeplink-backbar" role="navigation" aria-label="{{ "Back navigation"|trans }}">
<button id="ots-deeplink-back" type="button">
{# OTS combined editor bar — back button + layout name + action buttons #}
<div id="ots-editor-bar" role="banner" aria-label="{{ "Layout editor"|trans }}">
<button id="ots-bar-back" type="button" aria-label="{{ "Back"|trans }}">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Back"|trans }}
</button>
<span class="ots-deeplink-wordmark">OTS Signs</span>
</div>
<div id="ots-bar-divider" aria-hidden="true"></div>
<span id="ots-bar-label">
{{ layout.layout }}
<span class="ots-bar-status" id="ots-bar-status-badge">{{ "Draft"|trans }}</span>
</span>
{# Interactive mode toggle — available in both draft and read-only #}
<button id="ots-editor-interactive-btn" type="button" class="ots-editor-bar-btn"
title="{{ "Toggle interactive mode to link regions with actions"|trans }}">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M4 2l2 9 2.5-3H12L4 2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>
{{ "Interactive"|trans }}
</button>
<div id="ots-bar-actions-divider" aria-hidden="true"></div>
{# Draft state: Save + Publish + Discard #}
<button id="ots-editor-save-btn" type="button" class="ots-editor-bar-btn ots-draft-only"
title="{{ "Save current changes"|trans }}">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M8 2v7m0 0L5 6m3 3l3-3M3 13h10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Save"|trans }}
</button>
<button id="ots-editor-publish-btn" type="button" class="ots-editor-bar-btn ots-draft-only"
title="{{ "Publish layout to displays"|trans }}">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M8 11V4m0 0L5 7m3-3l3 3M3 13h10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Publish"|trans }}
</button>
<button id="ots-editor-discard-btn" type="button" class="ots-editor-bar-btn ots-draft-only"
title="{{ "Discard this draft and revert to the published version"|trans }}">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
{{ "Discard"|trans }}
</button>
{# Read-only/published state: Checkout + Schedule #}
<button id="ots-editor-checkout-btn" type="button" class="ots-editor-bar-btn ots-readonly-only"
title="{{ "Checkout this layout for editing"|trans }}">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M10.5 2.5l3 3L5 14H2v-3L10.5 2.5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 4l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{{ "Checkout"|trans }}
</button>
<button id="ots-editor-schedule-btn" type="button" class="ots-editor-bar-btn ots-readonly-only"
title="{{ "Schedule this layout on displays"|trans }}">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 5v3.5l2.5 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Schedule"|trans }}
</button>
{# Unlock: hidden by default, revealed via JS when lD marks the layout as locked-for-user #}
<button id="ots-editor-unlock-btn" type="button" class="ots-editor-bar-btn"
title="{{ "Unlock this layout"|trans }}">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<rect x="3" y="8" width="10" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 8V5a3 3 0 016 0v2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{{ "Unlock"|trans }}
</button>
</div> {# /ots-editor-bar #}
<!-- Editor structure -->
<div id="layout-editor" data-published-layout-id="{{ publishedLayoutId }}" data-layout-id="{{ layout.layoutId }}" data-layout-help={{ help }}></div>
{# Skeleton loading screen — replaced by editor once editor-opened class fires on body #}
<div id="ots-editor-skeleton" aria-hidden="true">
<div class="ots-skeleton-topbar">
<div class="ots-skeleton-topbar-left">
<div class="ots-skeleton-chip"></div>
<div class="ots-skeleton-chip" style="width:80px"></div>
</div>
<div class="ots-skeleton-topbar-right">
<div class="ots-skeleton-chip" style="width:150px"></div>
</div>
</div>
<div class="ots-skeleton-body">
<div class="ots-skeleton-left-rail">
<div class="ots-skeleton-icon-dot"></div>
<div class="ots-skeleton-icon-dot"></div>
<div class="ots-skeleton-icon-dot"></div>
<div class="ots-skeleton-icon-dot"></div>
</div>
<div class="ots-skeleton-canvas-area">
<div class="ots-skeleton-canvas-box"></div>
</div>
<div class="ots-skeleton-props">
<div class="ots-skeleton-prop-group">
<div class="ots-skeleton-prop-label"></div>
<div class="ots-skeleton-prop-swatch-row">
<div class="ots-skeleton-prop-swatch"></div>
<div class="ots-skeleton-prop-field"></div>
</div>
</div>
<div class="ots-skeleton-prop-group">
<div class="ots-skeleton-prop-label"></div>
<div class="ots-skeleton-prop-field" style="height:70px"></div>
</div>
<div class="ots-skeleton-prop-group">
<div class="ots-skeleton-prop-label"></div>
<div class="ots-skeleton-prop-field"></div>
</div>
<div class="ots-skeleton-prop-group">
<div class="ots-skeleton-prop-label" style="width:35%"></div>
<div class="ots-skeleton-prop-field"></div>
</div>
</div>
</div>
<div class="ots-skeleton-bottombar">
<div class="ots-skeleton-chip" style="width:120px; opacity:.5"></div>
</div>
</div>
{% endblock %}
{% block javaScript %}
@@ -633,57 +905,6 @@
}
</script>
{# ── Skeleton dismiss + keyboard shortcut hints ──────────── #}
<script type="text/javascript" nonce="{{ cspNonce }}">
(function() {
'use strict';
// ── Skeleton dismiss ─────────────────────────────────────
var skeleton = document.getElementById('ots-editor-skeleton');
if (skeleton) {
if (document.body.classList.contains('editor-opened')) {
skeleton.parentNode.removeChild(skeleton);
} else {
var skeletonObs = new MutationObserver(function() {
if (document.body.classList.contains('editor-opened')) {
skeleton.classList.add('ots-skeleton-done');
setTimeout(function() {
if (skeleton.parentNode) skeleton.parentNode.removeChild(skeleton);
}, 380);
skeletonObs.disconnect();
}
});
skeletonObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
}
// ── Keyboard shortcut hints (title attrs on toolbar buttons) ──
var hints = [
{ sel: '#undoBtn', hint: 'Undo (Ctrl+Z)' },
{ sel: '#fullscreenBtn', hint: 'Toggle Fullscreen (F)' },
{ sel: '#layerManagerBtn', hint: 'Layer Manager (L)' }
];
function applyKbHints() {
hints.forEach(function(h) {
try {
document.querySelectorAll(h.sel).forEach(function(el) {
if (!el.title) el.title = h.hint;
});
} catch(ignore) {}
});
}
var kbObs = new MutationObserver(function() {
if (document.body.classList.contains('editor-opened')) {
setTimeout(applyKbHints, 1500);
kbObs.disconnect();
}
});
kbObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
})();
</script>
{# ── Embed mode: postMessage bridge ──────────────────────── #}
<script type="text/javascript" nonce="{{ cspNonce }}">
(function() {
@@ -829,16 +1050,17 @@
})();
</script>
{# ── Deep-link mode: returnUrl back navigation ────────────── #}
{# ── Back bar: returnUrl navigation (always active) ────────── #}
<script type="text/javascript" nonce="{{ cspNonce }}">
(function() {
'use strict';
var params = new URLSearchParams(window.location.search);
if (params.get('deeplink') !== '1') return;
// Mirror the html-level class onto body (needed for body-targeted CSS rules).
document.body.classList.add('ots-deeplink-mode');
// Still mirror deeplink class for any other deeplink-specific behaviour.
if (params.get('deeplink') === '1') {
document.body.classList.add('ots-deeplink-mode');
}
/**
* Allow only safe same-origin relative paths.
@@ -857,7 +1079,7 @@
var rawReturn = params.get('returnUrl');
var safeReturn = validateReturnUrl(rawReturn) ? rawReturn : null;
var backBtn = document.getElementById('ots-deeplink-back');
var backBtn = document.getElementById('ots-bar-back');
if (backBtn) {
backBtn.addEventListener('click', function() {
if (safeReturn) {
@@ -869,4 +1091,234 @@
}
})();
</script>
{# ── OTS Editor Action Bar: state management & button wiring ── #}
<script type="text/javascript" nonce="{{ cspNonce }}">
(function() {
'use strict';
var bar = document.getElementById('ots-editor-bar');
var saveBtn = document.getElementById('ots-editor-save-btn');
var publishBtn = document.getElementById('ots-editor-publish-btn');
var checkoutBtn = document.getElementById('ots-editor-checkout-btn');
var discardBtn = document.getElementById('ots-editor-discard-btn');
var interactiveBtn = document.getElementById('ots-editor-interactive-btn');
var scheduleBtn = document.getElementById('ots-editor-schedule-btn');
var unlockBtn = document.getElementById('ots-editor-unlock-btn');
var statusBadge = document.getElementById('ots-bar-status-badge');
if (!bar || !saveBtn || !publishBtn || !checkoutBtn) return;
// ── State management ──────────────────────────────────────
// 'draft' → Save + Publish visible, Checkout hidden
// 'readonly' → Checkout visible, Save + Publish hidden
function setBarState(state) {
if (state === 'readonly') {
bar.classList.add('ots-state-readonly');
if (statusBadge) {
statusBadge.textContent = '{{ "Published"|trans }}';
statusBadge.classList.add('ots-status-published');
}
} else {
bar.classList.remove('ots-state-readonly');
if (statusBadge) {
statusBadge.textContent = '{{ "Draft"|trans }}';
statusBadge.classList.remove('ots-status-published');
}
}
}
// ── Detect initial state from data attributes ─────────────
// publishedLayoutId === layoutId → viewing the published version → read-only
// publishedLayoutId !== layoutId → viewing a draft copy → editable
function detectInitialState() {
var editorEl = document.getElementById('layout-editor');
if (!editorEl) return;
var layoutId = editorEl.getAttribute('data-layout-id');
var publishedId = editorEl.getAttribute('data-published-layout-id');
if (publishedId && publishedId !== '0' && publishedId === layoutId) {
setBarState('readonly');
} else {
setBarState('draft');
}
}
// ── Wait for the React editor to signal it's ready ────────
if (document.body.classList.contains('editor-opened')) {
detectInitialState();
} else {
var initObs = new MutationObserver(function() {
if (document.body.classList.contains('editor-opened')) {
initObs.disconnect();
detectInitialState();
}
});
initObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
// ── Button: Save ──────────────────────────────────────────
// Xibo auto-saves widget/region changes via the history manager.
// The save button's job is to flush any pending properties panel form.
saveBtn.addEventListener('click', function() {
if (saveBtn.classList.contains('ots-btn-loading')) return;
try {
if (typeof lD !== 'undefined' && lD.propertiesPanel) {
lD.propertiesPanel.save({ target: lD.selectedObject });
}
} catch(e) { /* silent */ }
});
// ── Button: Publish ───────────────────────────────────────
publishBtn.addEventListener('click', function() {
if (publishBtn.classList.contains('ots-btn-loading')) return;
try {
if (typeof lD !== 'undefined' && typeof lD.showPublishScreen === 'function') {
lD.showPublishScreen();
}
} catch(e) { /* silent */ }
});
// ── Button: Checkout ──────────────────────────────────────
// Use urlsForApi (set by editorVars.twig) so the URL is always correct
// regardless of any sub-path the CMS is hosted under.
checkoutBtn.addEventListener('click', function() {
// Delegate to Xibo's own checkout implementation, which handles
// the AJAX call and redirect internally using urlsForApi.
if (typeof lD === 'undefined' || !lD.layout ||
typeof lD.layout.checkout !== 'function') {
toastr.error('Editor not ready. Please refresh and try again.');
return;
}
lD.layout.checkout();
});
// ── Button: Discard ───────────────────────────────────────
// We handle this manually so we can redirect back to the custom app
// (returnUrl) instead of letting Xibo redirect to its own layouts page.
if (discardBtn) {
discardBtn.addEventListener('click', function() {
if (discardBtn.classList.contains('ots-btn-loading')) return;
if (!confirm('{{ "Discard this draft and revert to the published version?"|trans }}')) return;
var editorEl = document.getElementById('layout-editor');
if (!editorEl) return;
// Discard endpoint takes the published (parent) layout ID.
var publishedId = editorEl.getAttribute('data-published-layout-id');
if (!publishedId) return;
if (typeof urlsForApi === 'undefined' ||
!urlsForApi.layout || !urlsForApi.layout.discard) return;
discardBtn.classList.add('ots-btn-loading');
discardBtn.disabled = true;
$.ajax({
type: urlsForApi.layout.discard.type || 'PUT',
url: urlsForApi.layout.discard.url.replace(':id', publishedId),
success: function() {
// Redirect to returnUrl (same validation as Back button)
var rawReturn = new URLSearchParams(window.location.search).get('returnUrl');
var safeReturn = (rawReturn &&
rawReturn.charAt(0) === '/' &&
rawReturn.charAt(1) !== '/' &&
rawReturn.indexOf('://') === -1) ? rawReturn : null;
window.location.href = safeReturn || '/';
},
error: function(xhr) {
discardBtn.classList.remove('ots-btn-loading');
discardBtn.disabled = false;
try {
var err = xhr.responseJSON || JSON.parse(xhr.responseText);
toastr.error(err.message || '{{ "Discard failed"|trans }}');
} catch(e) {
toastr.error('{{ "Discard failed"|trans }}');
}
}
});
});
}
// ── Button: Interactive mode toggle ───────────────────────
if (interactiveBtn) {
interactiveBtn.addEventListener('click', function() {
try {
if (typeof lD !== 'undefined' && typeof lD.toggleInteractiveMode === 'function') {
lD.toggleInteractiveMode(!lD.interactiveMode);
}
} catch(e) { /* silent */ }
});
}
// ── Button: Schedule ──────────────────────────────────────
if (scheduleBtn) {
scheduleBtn.addEventListener('click', function() {
if (scheduleBtn.classList.contains('ots-btn-loading')) return;
try {
if (typeof lD !== 'undefined' && typeof lD.showScheduleScreen === 'function') {
lD.showScheduleScreen();
}
} catch(e) { /* silent */ }
});
}
// ── Button: Unlock ────────────────────────────────────────
if (unlockBtn) {
unlockBtn.addEventListener('click', function() {
try {
if (typeof lD !== 'undefined' && typeof lD.showUnlockScreen === 'function') {
lD.showUnlockScreen();
}
} catch(e) { /* silent */ }
});
}
// ── Watch #layout-editor classList for interactive-mode and locked state ──
(function() {
var editorEl = document.getElementById('layout-editor');
if (!editorEl) return;
new MutationObserver(function() {
// Sync interactive button visual toggle state
if (interactiveBtn) {
interactiveBtn.classList.toggle(
'ots-btn-active',
editorEl.classList.contains('interactive-mode') ||
editorEl.classList.contains('interactive-edit-widget-mode')
);
}
// Show Unlock button when lD adds 'locked' + 'locked-for-user' classes
bar.classList.toggle('ots-bar-locked', editorEl.classList.contains('locked-for-user'));
}).observe(editorEl, { attributes: true, attributeFilter: ['class'] });
})();
// ── AJAX intercepts: auto-update bar state ────────────────
$(document).ajaxComplete(function(event, xhr, settings) {
if (!settings || !settings.url) return;
try {
var response = xhr.responseJSON || {};
if (!response.success) return;
var url = settings.url;
// Layout draft save (PUT /layout/{id}) → flash Save button
if (settings.type === 'PUT' && /\/layout\/\d+(?:$|\?|\/(?!publish|checkout|discard|delete))/.test(url)) {
saveBtn.classList.add('ots-btn-saved');
setTimeout(function() {
saveBtn.classList.remove('ots-btn-saved');
}, 2000);
}
// Publish → layout is now the published version → read-only
if (/\/layout\/publish\/\d+/.test(url)) {
setBarState('readonly');
}
// Checkout → a new draft is now active → editable
if (/\/layout\/checkout\/\d+/.test(url)) {
setBarState('draft');
}
} catch(e) { /* silent */ }
});
})();
</script>
{% endblock %}

View File

@@ -1,528 +0,0 @@
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Layouts"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Layouts" %}</h1>
<p class="text-muted">{% trans "Manage and design your layouts." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-type="layout" data-grid-name="layoutView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Layouts" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab" aria-selected="true"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab" aria-selected="false"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline d-block">
<div class="tab-content">
<div class="tab-pane active" id="general-filter" role="tabpanel">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("campaignId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('layout', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input('codeLike', title) }}
{% if currentUser.featureEnabled("displaygroup.view") %}
{% set title %}{% trans "Display Group" %}{% endset %}
{% set helpText %}{% trans "Show Layouts active on the selected Display / Display Group" %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("displayGroup.search") },
{ name: "data-filter-options", value: '{"isDisplaySpecific":-1}' },
{ name: "data-search-term", value: "displayGroup" },
{ name: "data-id-property", value: "displayGroupId" },
{ name: "data-text-property", value: "displayGroup" },
{ name: "data-initial-key", value: "displayGroupId" },
] %}
{{ inline.dropdown("activeDisplayGroupId", "single", title, "", null, "displayGroupId", "displayGroup", helpText, "pagedSelect", "", "", "", attributes) }}
{% endif %}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Owner User Group" %}{% endset %}
{% set helpText %}{% trans "Show items owned by users in the selected User Group." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("group.search") },
{ name: "data-search-term", value: "group" },
{ name: "data-id-property", value: "groupId" },
{ name: "data-text-property", value: "group" },
{ name: "data-initial-key", value: "userGroupId" },
] %}
{{ inline.dropdown("ownerUserGroupId", "single", title, "", null, "groupId", "group", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Landscape"|trans %}
{% set option3 = "Portrait"|trans %}
{% set values = [{id: '', value: option1}, {id: 'landscape', value: option2}, {id: 'portrait', value: option3}] %}
{{ inline.dropdown("orientation", "single", title, '', values, "id", "value") }}
{{ inline.hidden("folderId") }}
</div>
<div class="tab-pane" id="advanced-filter" role="tabpanel">
{% set title %}{% trans "Retired" %}{% endset %}
{% set option1 = "No"|trans %}
{% set option2 = "Yes"|trans %}
{% set values = [{id: 0, value: option1}, {id: 1, value: option2}] %}
{{ inline.dropdown("retired", "single", title, 0, values, "id", "value") }}
{% set title %}{% trans "Show" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Only Used"|trans %}
{% set option3 = "Only Unused"|trans %}
{% set values = [{id: 1, value: option1}, {id: 2, value: option2}, {id: 3, value: option3}] %}
{{ inline.dropdown("layoutStatusId", "single", title, 1, values, "id", "value") }}
{% set title %}{% trans "Description" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "1st line"|trans %}
{% set option3 = "Widget List"|trans %}
{% set values = [{id: 1, value: option1}, {id: 2, value: option2}, {id: 3, value: option3}] %}
{{ inline.dropdown("showDescriptionId", "single", title, 2, values, "id", "value") }}
{% if currentUser.featureEnabled("library.view") %}
{% set title %}{% trans "Media" %}{% endset %}
{{ inline.input("mediaLike", title) }}
{% endif %}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title) }}
{% set title %}{% trans "Modified Since" %}{% endset %}
{{ inline.date("modifiedSinceDt", title) }}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("layout.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn layout-add-button" title="{% trans "Add a new Layout and jump to the layout editor." %}" href="{{ url_for("layout.add") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
<button class="btn btn-sm btn-outline-secondary ots-toolbar-btn" id="layoutUploadForm" title="{% trans "Import a Layout from a ZIP file." %}" href="#"><i class="fa fa-cloud-download" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="layouts" class="table table-striped responsive nowrap" data-content-type="layout" data-content-id-name="layoutId" data-state-preference-name="layoutGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Duration" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Orientation" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Valid?" %}</th>
<th>{% trans "Stats?" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Layout ID" %}</th>
<th>{% trans "Code" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#layouts").DataTable({
language: dataTablesLanguage,
lengthMenu: [10, 25, 50, 100, 250, 500],
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
dataType: 'json',
order: [[1, "asc"]],
ajax: {
url: "{{ url_for("layout.search") }}",
data: function (d) {
$.extend(d, $("#layouts").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
columns: [
{"data": "campaignId", responsivePriority: 1},
{
"data": "layout",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"name": "publishedStatus",
responsivePriority: 2,
"data": function (data, type) {
if (data.publishedDate != null) {
var now = moment();
var published = moment(data.publishedDate);
var differenceMinutes = published.diff(now, 'minutes');
var momentDifference = moment(now).to(published);
if (differenceMinutes < -5) {
return data.publishedStatus.concat(" - ", translations.publishedStatusFailed);
} else {
return data.publishedStatus.concat(" - ", translations.publishedStatusFuture + " " + momentDifference);
}
} else {
return data.publishedStatus;
}
}
},
{
"name": "description",
"data": null,
responsivePriority: 10,
"render": {"_": "description", "display": "descriptionFormatted", "sort": "description"}
},
{
"name": "duration",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.duration;
return dataTableTimeFromSeconds(data.duration, type);
}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
responsivePriority: 3,
"data": dataTableCreateTags
},{% endif %}
{ data: 'orientation', responsivePriority: 10, visible: false},
{
responsivePriority: 5,
data: 'thumbnail',
render: function(data, type, row) {
if (type !== 'display') {
return row.layoutId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
} else {
var addUrl = '{{ url_for("layout.thumbnail.add", {id: ":id"}) }}'.replace(':id', row.layoutId);
return '<a class="img-replace generate-layout-thumbnail" data-type="image" href="' + addUrl + '">' +
'<img class="img-fluid" src="{{ theme.uri("img/thumbs/placeholder.png") }}" alt="{{ "Add Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"name": "status",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.status;
var icon = "";
if (data.status == 1)
icon = "fa-check";
else if (data.status == 2)
icon = "fa-exclamation";
else if (data.status == 3)
icon = "fa-cogs";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.statusDescription) + ((data.statusMessage == null) ? "" : " - " + (data.statusMessage)) + '"></span>';
}
},
{
"name": "enableStat",
responsivePriority: 4,
"data": function (data) {
var icon = "";
if (data.enableStat == 1)
icon = "fa-check";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.enableStatDescription) + '"></span>';
}
},
{
"data": "createdDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
data: "modifiedDt",
responsivePriority: 6,
render: dataTableDateFromIso,
visible: true
},
{
data: "layoutId",
visible: false,
responsivePriority: 4
},
{"data": "code", "visible":false, responsivePriority: 4},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#layouts").closest(".XiboGrid").find(".FilterDiv form") }, dataTableCreateTagEvents);
table.on('draw', function(e, settings) {
$('#' + e.target.id + ' .generate-layout-thumbnail').on('click', function(e) {
e.preventDefault();
var $anchor = $(this);
$.ajax({
url: $anchor.attr('href'),
method: 'POST',
success: function() {
$anchor.find('img').attr('src', $anchor.attr('href'));
$anchor.removeClass('generate-layout-thumbnail').attr('data-toggle', 'lightbox');
}
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#layouts_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function() {
table.ajax.reload();
});
// Bind to the layout add button
$('button.layout-add-button').on('click', function() {
let currentWorkingFolderId =
$("#layouts")
.closest(".XiboGrid")
.find(".FilterDiv form")
.find('#folderId').val()
// Submit the URL provided as a POST request.
$.ajax({
type: 'POST',
url: $(this).attr('href'),
cache: false,
data : {folderId : currentWorkingFolderId},
dataType: 'json',
success: function(response, textStatus, error) {
if (response.success && response.id) {
XiboRedirect('{{ url_for("layout.designer", {id: ':id'}) }}'.replace(':id', response.id));
} else {
if (response.login) {
LoginBox(response.message);
} else {
SystemMessage(response.message ?? '{{ "Unknown Error"|trans }}', false);
}
}
},
error: function(xhr, textStatus, errorThrown) {
SystemMessage(xhr.responseText, false);
},
});
});
});
$("#layoutUploadForm").click(function(e) {
e.preventDefault();
var currentWorkingFolderId = $('#folderId').val();
// Open the upload dialog with our options.
openUploadForm({
url: "{{ url_for("layout.import") }}",
title: "{{ "Upload Layout"|trans }}",
videoImageCovers: false,
buttons: {
main: {
label: "{{ "Done"|trans }}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
layoutImport: true,
updateInAllChecked: {% if settings.LIBRARY_MEDIA_UPDATEINALL_CHECKB == 1 %}true{% else %}false{% endif %},
deleteOldRevisionsChecked: {% if settings.LIBRARY_MEDIA_DELETEOLDVER_CHECKB == 1 %}true{% else %}false{% endif %},
trans: {
addFiles: "{{ "Add Layout Export ZIP Files"|trans }}",
startUpload: "{{ "Start Import"|trans }}",
cancelUpload: "{{ "Cancel Import"|trans }}",
replaceExistingMediaMessage: "{{ "Replace Existing Media?"|trans }}",
importTagsMessage: "{{ "Import Tags?"|trans }}",
useExistingDataSetsMessage: "{{ "Use existing DataSets matched by name?"|trans }}",
dataSetDataMessage: "{{ "Import DataSet Data?"|trans }}",
fallbackMessage: "{{ "Import Widget Fallback Data?"|trans }}",
selectFolder: "{{ "Select Folder"|trans }}",
selectFolderTitle: "{{ "Change Current Folder location"|trans }}",
selectedFolder: "{{ "Current Folder"|trans }}:",
selectedFolderTitle: "{{ "Upload files to this Folder"|trans }}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "zip"
},
currentWorkingFolderId: currentWorkingFolderId,
folderSelector: true
},
formOpenedEvent: function () {
// Configure the active behaviour of the checkboxes
$("#useExistingDataSets").on("click", function () {
$("#importDataSetData").prop("disabled", ($(this).is(":checked")));
});
},
uploadDoneEvent: function (data) {
XiboDialogClose();
table.ajax.reload();
}
});
});
function layoutExportFormSubmit() {
var $form = $("#layoutExportForm");
window.location = $form.attr("action") + "?" + $form.serialize();
setTimeout(function() {
XiboDialogClose();
}, 1000);
}
function assignLayoutToCampaignFormSubmit() {
var form = $("#layoutAssignCampaignForm");
var url = form.prop("action").replace(":id", form.find("#campaignId").val());
$.ajax({
type: form.attr("method"),
url: url,
data: {layoutId: form.data().layoutId},
cache: false,
dataType:"json",
success: XiboSubmitResponse
});
}
function setEnableStatMultiSelectFormOpen(dialog) {
var $input = $('<input type=checkbox id="enableStat" name="enableStat"> {{ "Enable Stats Collection?"|trans }} </input>');
var $helpText = $('<span class="help-block">{{ "Check to enable the collection of Proof of Play statistics for the selected items."|trans }}</span>');
$input.on('change', function() {
dialog.data().commitData = {enableStat: $(this).val()};
});
$(dialog).find('.modal-body').append($input);
$(dialog).find('.modal-body').append($helpText);
}
function layoutPublishFormOpen() {
// Nothing to do here, but we use the same form on the layout designer and have a callback registered there
}
function layoutEditFormSaved() {
// Nothing to do here.
}
</script>
{% endblock %}

View File

@@ -1,580 +0,0 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Library"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Media" %}</h1>
<p class="text-muted">{% trans "Manage your media library." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="libraryView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Media" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("mediaId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('media', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set attributes = [
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" }
] %}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("ownerId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Owner User Group" %}{% endset %}
{% set helpText %}{% trans "Show items owned by users in the selected User Group." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("group.search") },
{ name: "data-search-term", value: "group" },
{ name: "data-id-property", value: "groupId" },
{ name: "data-text-property", value: "group" },
{ name: "data-initial-key", value: "userGroupId" },
] %}
{{ inline.dropdown("ownerUserGroupId", "single", title, "", null, "groupId", "group", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Type" %}{% endset %}
{{ inline.dropdown("type", "single", title, "", [{"type": none, "name": ""}]|merge(modules), "type", "name") }}
{% set title %}{% trans "Retired" %}{% endset %}
{% set values = [{id: 0, value: "No"}, {id: 1, value: "Yes"}] %}
{{ inline.dropdown("retired", "single", title, 0, values, "id", "value") }}
{{ inline.hidden("folderId") }}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title, layoutId) }}
{% set title %}{% trans "Orientation" %}{% endset %}
{% set option1 = "All"|trans %}
{% set option2 = "Landscape"|trans %}
{% set option3 = "Portrait"|trans %}
{% set values = [{id: '', value: option1}, {id: 'landscape', value: option2}, {id: 'portrait', value: option3}] %}
{{ inline.dropdown("orientation", "single", title, '', values, "id", "value") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabledCount(["library.add", "library.modify"]) > 0 or settings.SETTING_LIBRARY_TIDY_ENABLED == 1 %}
{% if currentUser.featureEnabled("library.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn" href="#" id="libraryUploadForm" title="{% trans "Add a new media item to the library" %}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button> {% endif %}
{% if settings.SETTING_LIBRARY_TIDY_ENABLED == 1 and currentUser.featureEnabled("library.modify") %}
<button class="btn btn-sm btn-warning ots-toolbar-btn XiboFormButton btn-tidy" title="{% trans "Run through the library and remove unused and unnecessary files" %}" href="{{ url_for("library.tidy.form") }}"><i class="fa fa-broom" aria-hidden="true"></i></button>
{% endif %}
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="libraryItems" class="table table-striped responsive nowrap" data-content-type="media" data-content-id-name="mediaId" data-state-preference-name="libraryGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Type" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tag" %}</th>{% endif %}
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Duration" %}</th>
<th>{% trans "Duration (seconds)" %}</th>
<th>{% trans "Size" %}</th>
<th>{% trans "Size (bytes)" %}</th>
<th>{% trans "Resolution" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Revised" %}</th>
<th>{% trans "Released" %}</th>
<th>{% trans "File Name" %}</th>
<th>{% trans "Stats?" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Expires" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#libraryItems").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.search") }}",
"data": function (d) {
$.extend(d, $("#libraryItems").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "mediaId", responsivePriority: 2},
{"data": "name", "render": dataTableSpacingPreformatted, responsivePriority: 3 },
{"data": "mediaType", responsivePriority: 2},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
responsivePriority: 2,
"visible": false,
"data": dataTableCreateTags
},{% endif %}
{
responsivePriority: 5,
data: 'thumbnail',
render: function(data, type, row) {
if (type !== 'display') {
return row.mediaId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data.replace('download', 'thumbnail') + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{
"name": "duration",
responsivePriority: 3,
"data": function (data, type) {
if (type != "display")
return data.duration;
return dataTableTimeFromSeconds(data.duration, type);
}
},
{"data": "duration", "visible": false, responsivePriority: 10},
{
"name": "fileSize",
responsivePriority: 3,
"data": null,
"render": {"_": "fileSize", "display": "fileSizeFormatted", "sort": "fileSize"}
},
{"data": "fileSize", "visible": false, responsivePriority: 10},
{
name: 'width',
data: function(data, type, row, meta) {
if (type !== 'display' || data.width === 0 || data.height === 0) {
return '';
}
return data.width + 'x' + data.height;
},
visible: false,
responsivePriority: 10
},
{"data": "owner", responsivePriority: 5},
{
"data": "groupsWithPermissions",
responsivePriority: 5,
"render": dataTableCreatePermissions
},
{"data": "revised", "render": dataTableTickCrossColumn, "visible": false, responsivePriority: 6},
{
"name": "released",
responsivePriority: 6,
"data": function (data, type) {
if (type != "display")
return data.released;
var icon = "";
if (data.released == 1)
icon = "fa-check";
else if (data.released == 0)
icon = "fa-cogs";
else if (data.released == 2)
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (data.releasedDescription) + '"></span>';
},
"visible": false
},
{"data": "fileName", responsivePriority: 500},
{
"name": "enableStat",
responsivePriority: 6,
"data": function (data) {
var icon = "";
if (data.enableStat == 'On')
icon = "fa-check";
else if (data.enableStat == 'Off')
icon = "fa-times";
else
icon = "fa-level-down";
return '<span class="fa ' + icon + '" title="' + (data.enableStatDescription) + '"></span>';
}
},
{
"data": "createdDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"data": "modifiedDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"name": "expires",
responsivePriority: 6,
"data": function (data, type) {
if (data.expires != null && data.expires != 0) {
var now = moment();
var expiresIn = moment.unix(data.expires);
var differenceMinutes = expiresIn.diff(now, 'minutes');
var momentDifference = moment(now).to(expiresIn);
if (differenceMinutes < -10 ) {
return data.mediaExpiryFailed;
} else {
return data.mediaExpiresIn.replace('%s', momentDifference);
}
} else {
return data.mediaNoExpiryDate;
}
}
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#libraryItems").closest(".XiboGrid").find(".FilterDiv form") } ,dataTableCreateTagEvents);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#libraryItems_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
$("#libraryUploadForm").click(function(e) {
e.preventDefault();
var currentWorkingFolderId = $('#folderId').val();
openUploadForm({
url: "{{ url_for("library.add") }}",
title: "{% trans "Add Media" %}",
initialisedBy: "library-upload",
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
trans: {
addFiles: "{% trans "Add files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}",
selectFolder: "{% trans "Select Folder" %}",
selectFolderTitle: "{% trans "Change Current Folder location" %}",
selectedFolder: "{% trans "Current Folder" %}:",
selectedFolderTitle: "{% trans "Upload files to this Folder" %}",
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ validExt }}"
},
updateInAllChecked: {% if settings.LIBRARY_MEDIA_UPDATEINALL_CHECKB == 1 %}true{% else %}false{% endif %},
deleteOldRevisionsChecked: {% if settings.LIBRARY_MEDIA_DELETEOLDVER_CHECKB == 1 %}true{% else %}false{% endif %},
currentWorkingFolderId: currentWorkingFolderId,
folderSelector: true
}
});
});
/**
* Media Edit form
*/
function mediaEditFormOpen(dialog) {
// ── OTS: Style the edit-media modal to match the upload modal ──
// dialog IS the .modal element (returned by bootbox.dialog())
dialog.addClass('ots-edit-media-modal');
// Also apply via the global enhancer in case the class wasn't added
if (typeof window.otsEnhanceModal === 'function') {
window.otsEnhanceModal(dialog);
}
// Create a new button
var footer = dialog.find(".modal-footer");
var mediaId = dialog.find("#mediaEditForm").data().mediaId;
var validExtensions = dialog.find("#mediaEditForm").data().validExtensions;
var folderId = dialog.find("#mediaEditForm").data().folderId;
// Append
var replaceButton = $('<button class="btn btn-warning">{% trans "Replace" %}</button>');
replaceButton.click(function(e) {
e.preventDefault();
// Open the upload dialog with our options.
openUploadForm({
url: "{{ url_for("library.add") }}",
title: "{% trans "Upload media" %}",
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
multi: false,
oldMediaId: mediaId,
oldFolderId: folderId,
updateInAllChecked: {% if settings.LIBRARY_MEDIA_UPDATEINALL_CHECKB == 1 %}true{% else %}false{% endif %},
deleteOldRevisionsChecked: {% if settings.LIBRARY_MEDIA_DELETEOLDVER_CHECKB == 1 %}true{% else %}false{% endif %},
trans: {
addFiles: "{% trans "Add Replacement" %}",
startUpload: "{% trans "Start Replace" %}",
cancelUpload: "{% trans "Cancel Replace" %}",
updateInLayouts: {
title: "{% trans "Update this media in all layouts it is assigned to?" %}",
helpText: "{% trans "Note: It will only be updated in layouts you have permission to edit." %}"
},
deleteOldRevisions: {
title: "{% trans "Delete the old version?" %}",
helpText: "{% trans "Completely remove the old version of this media item if a new file is being uploaded." %}"
}
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: validExtensions,
validExtensionsMessage: "{{ "Valid extensions are %s" }}".replace("%s", validExtensions).replace(/\|/g, ", ")
}
},
uploadDoneEvent: function () {
XiboDialogClose();
table.ajax.reload();
}
});
});
footer.find(".btn-primary").before(replaceButton);
}
///
/// Library Usage Form
///
function usageFormOpen(dialog) {
// Displays tab
var usageTable = $("#usageReportTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.usage", {id: ':id'}) }}".replace(":id", $("#usageReportTable").data().mediaId),
"data": function(dataDisplay) {
$.extend(dataDisplay, $(dialog).find("#usageReportForm").serializeObject());
return dataDisplay;
}
},
"columns": [
{ "data": "displayId"},
{ "data": "display" },
{ "data": "description" }
]
});
usageTable.on('draw', dataTableDraw);
usageTable.on('processing.dt', dataTableProcessing);
// Layouts tab
var usageTableLayouts = $("#usageReportLayoutsTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.usage.layouts", {id: ':id'}) }}".replace(":id", $("#usageReportLayoutsTable").data().mediaId)
},
"columns": [
{ "data": "layoutId"},
{ "data": "layout" },
{ "data": "description" },
{
"orderable": false,
"data": dataTableButtonsColumn
}
]
});
usageTableLayouts.on('draw', dataTableDraw);
usageTableLayouts.on('processing.dt', dataTableProcessing);
}
function setDefaultMultiSelectFormOpen(dialog) {
{% set message = 'Force delete from any existing layouts, assignments, etc' %}
{% set message2 = 'Notify each Display that has this Media in its local storage to remove it immediately?' %}
var $input = $(
'<div class="form-group">' +
'<input type=checkbox id="forceDelete" name="forceDelete"> {{ message|trans|e }} </input>' +
'</div>'
);
var $input2 = $(
'<div class="form-group">' +
'<input type=checkbox id="purge" name="purge"> {{ message2|trans|e }} </input>' +
'</div>'
);
$(dialog).find('.modal-body').append($input, $input2);
$('#forceDelete, #purge').on('change', function() {
dialog.data().commitData = {
forceDelete: $('#forceDelete').val(),
purge: $('#purge').val()
};
});
}
function setEnableStatMultiSelectFormOpen(dialog) {
var $select = $('<select id="enableStat" name="enableStat" class="form-control">' +
'<option value="Off">{% trans %} Off {% endtrans %}</option>' +
'<option value="On">{% trans %} On {% endtrans %}</option>' +
'<option value="Inherit">{% trans %} Inherit {% endtrans %}</option>' +
'</select>');
$select.on('change', function() {
dialog.data().commitData = {enableStat: $(this).val()};
}).trigger('change');
$(dialog).find('.modal-body').append($select);
}
</script>
{% endblock %}

View File

@@ -1,15 +1,6 @@
<!DOCTYPE html>
<html lang="en">
<head>
<script nonce="{{ cspNonce }}">
(function() {
if (window.location.search.indexOf('/layout/designer/') !== -1) return;
var path = window.location.pathname;
var cmsIdx = path.toLowerCase().indexOf('/cms');
var portalUrl = window.location.origin + (cmsIdx > 0 ? path.substring(0, cmsIdx) : '') + '/';
window.location.replace(portalUrl);
})();
</script>
<title>{{ theme.getThemeConfig("theme_title") }}</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
@@ -20,106 +11,129 @@
<!-- Import CSS bundle from dist -->
<script src="{{ theme.rootUri() }}dist/style.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<!-- Minimal inline adjustments (layout only) -->
<style type="text/css">
html { font-size: 14px; }
body { padding-top: 40px !important; padding-bottom: 40px !important; font-size: 1rem; }
<!-- Copyright 2006-2023 Xibo Signage Ltd. Part of the Xibo Open Source Digital Signage Solution. Released under the AGPLv3 or later. -->
<style type="text/css" nonce="{{ cspNonce }}">
html {
font-size: 14px;
}
body {
padding-top: 40px;
padding-bottom: 40px;
background-color: #f5f5f5;
font-size: 1rem;
}
.form-signin {
max-width: 300px;
padding: 19px 29px 29px;
margin: 0 auto 20px;
background-color: #fff;
border: 1px solid #e5e5e5;
-webkit-border-radius: 5px;
-moz-border-radius: 5px;
border-radius: 5px;
-webkit-box-shadow: 0 1px 2px rgba(0,0,0,.05);
-moz-box-shadow: 0 1px 2px rgba(0,0,0,.05);
box-shadow: 0 1px 2px rgba(0,0,0,.05);
}
.form-signin .form-signin-heading,
.form-signin .checkbox {
margin-bottom: 10px;
}
.form-signin input[type="text"],
.form-signin input[type="password"] {
font-size: 1.15rem;
height: auto;
margin-bottom: 15px;
padding: 7px 9px;
}
.login-logo {
width: 200px;
}
</style>
<!-- Import user made CSS from theme -->
<link href="{{ theme.uri("css/override.css") }}?{{ version }}" rel="stylesheet" media="screen">
<link href="{{ theme.uri("css/override-dark.css") }}?{{ version }}" rel="stylesheet" media="screen">
<link href="{{ theme.uri("css/override.css") }}?{{ version }}" rel="stylesheet" media="screen" nonce="{{ cspNonce }}">
</head>
<body class="login-page">
<div class="container">
{% if authCASEnabled %}
<form id="cas-login-form" class="login-card text-center" action="{{ url_for("cas.login") }}" method="post">
<body>
<div class="container">
{% if authCASEnabled %}
<form id="cas-login-form" class="form-signin text-center" action="{{ url_for("cas.login") }}" method="post">
{% for priorRoute in flash('priorRoute') %}
<input name="priorRoute" type="hidden" value="{{ priorRoute }}" />
{% endfor %}
<p class="login-brand"><img alt="Logo" class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"><span class="login-brand-text">OTS Signs</span></p>
<p><img class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"></p>
<p>{% trans %}Connect with the Central Authentication Server{% endtrans %}</p>
<div aria-live="polite">
{% for loginMessage in flash('cas_login_message') %}
<div class="alert alert-danger" role="alert">{{ loginMessage }}</div>
<div class="alert alert-danger">{{ loginMessage }}</div>
{% endfor %}
</div>
<p><button class="btn btn-signin" type="submit" name="logincas">{% trans %} CAS Login{% endtrans %}</button></p>
<p><button class="btn btn-primary" type="submit" name="logincas">{% trans %} CAS Login{% endtrans %}</button></p>
</form>
{% else %}
<form id="login-form" class="login-card text-center" action="{{ url_for("login") }}" method="post">
{% else %}
<form id="login-form" class="form-signin text-center" action="{{ url_for("login") }}" method="post">
{% for priorRoute in flash('priorRoute') %}
<input name="priorRoute" type="hidden" value="{{ priorRoute }}" />
{% endfor %}
<input type="hidden" name="{{ csrfKey }}" value="{{ csrfToken }}" />
<p class="login-brand"><a href="{{ theme.getThemeConfig("theme_url") }}"><img class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"></a><span class="login-brand-text">OTS Signs</span></p>
<p><a href="{{ theme.getThemeConfig("theme_url") }}"><img class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"></a></p>
<p class="lead">{% trans "Please provide your credentials" %}</p>
<p>{% trans "Please provide your credentials" %}</p>
<label for="username" class="sr-only">{% trans "User" %}</label>
<input id="username" class="form-control input-block-level" name="username" type="text" placeholder="{% trans "User" %}" autofocus autocomplete="username">
<label for="password" class="sr-only">{% trans "Password" %}</label>
<input id="password" class="form-control input-block-level" name="password" type="password" placeholder="{% trans "Password" %}" autocomplete="current-password">
<div aria-live="polite">
{% for loginMessage in flash('login_message') %}
<div class="alert alert-danger" role="alert">{{ loginMessage }}</div>
<div class="alert alert-danger">{{ loginMessage }}</div>
{% endfor %}
</div>
<p><button class="btn btn-signin" type="submit">{% trans "Login" %}</button></p>
<p><button class="btn btn-primary" type="submit">{% trans "Login" %}</button></p>
{% if passwordReminderEnabled %}<p><a href="#" id="reminder-form-toggle" role="button">{% trans "Forgotten your password?" %}</a></p>{% endif %}
{% if passwordReminderEnabled %}<p><a href="#" id="reminder-form-toggle">{% trans "Forgotten your password?" %}</a></p>{% endif %}
</form>
{% endif %}
{% endif %}
{% if passwordReminderEnabled %}
<form id="reminder-form" class="login-card text-center d-none" action="{{ url_for("login.forgotten") }}" method="post">
{% if passwordReminderEnabled %}
<form id="reminder-form" class="form-signin text-center d-none" action="{{ url_for("login.forgotten") }}" method="post">
<input type="hidden" name="{{ csrfKey }}" value="{{ csrfToken }}" />
<p class="login-brand"><a href="{{ theme.getThemeConfig("theme_url") }}"><img class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"></a><span class="login-brand-text">OTS Signs</span></p>
<p><a href="{{ theme.getThemeConfig("theme_url") }}"><img class="login-logo" src="{{ theme.uri("img/xibologo.png") }}"></a></p>
<p>{% trans "Please provide your user name" %}</p>
<label for="reminder-username" class="sr-only">{% trans "User" %}</label>
<input id="reminder-username" class="form-control input-block-level" name="username" type="text" placeholder="{% trans "User" %}">
<input id="username" class="form-control input-block-level" name="username" type="text" placeholder="{% trans "User" %}">
<div aria-live="polite">
{% for loginMessage in flash('login_message') %}
<div class="alert alert-danger" role="alert">{{ loginMessage }}</div>
<div class="alert alert-danger">{{ loginMessage }}</div>
{% endfor %}
</div>
<p><button class="btn btn-signin" type="submit">{% trans "Send Reset" %}</button></p>
<p><button class="btn btn-primary" type="submit">{% trans "Send Reset" %}</button></p>
<p><a href="#" id="login-form-toggle" role="button">{% trans "Login instead?" %}</a></p>
<p><a href="#" id="login-form-toggle">{% trans "Login instead?" %}</a></p>
</form>
{% endif %}
{% endif %}
</div> <!-- /container -->
<!-- Import JS bundle from dist -->
<script src="{{ theme.rootUri() }}dist/vendor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script type="text/javascript" nonce="{{ cspNonce }}">
$(function() {
$("#reminder-form-toggle").on("click keydown", function (e) {
if (e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
<p class="text-center">{% trans %}Version {{ version }}{% endtrans %}</p>
</div> <!-- /container -->
<!-- Import JS bundle from dist -->
<script src="{{ theme.rootUri() }}dist/vendor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script type="text/javascript" nonce="{{ cspNonce }}">
$(function() {
$("#reminder-form-toggle").on("click", function (e) {
e.preventDefault();
$("#login-form").addClass("d-none");
$("#reminder-form").removeClass("d-none");
$("#reminder-form").find("input:first").focus();
});
$("#login-form").addClass("d-none");
$("#reminder-form").removeClass("d-none");
});
$("#login-form-toggle").on("click keydown", function (e) {
if (e.type === "keydown" && e.key !== "Enter" && e.key !== " ") return;
e.preventDefault();
$("#login-form-toggle").on("click", function (e) {
e.preventDefault();
$("#login-form").removeClass("d-none");
$("#reminder-form").addClass("d-none");
$("#login-form").find("input:first").focus();
});
});
</script>
$("#login-form").removeClass("d-none");
$("#reminder-form").addClass("d-none");
});
});
</script>
</body>
</html>

View File

@@ -1,200 +0,0 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Menu Boards"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Menu Boards" %}</h1>
<p class="text-muted">{% trans "Manage your menu boards and content." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-type="menuBoard" data-grid-name="menuBoardView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Menu Boards" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("menuId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input('code', title) }}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("menuBoard.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Menu Board" %}" href="{{ url_for("menuBoard.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="menuBoards" class="table table-striped responsive nowrap" data-content-type="menuBoard" data-content-id-name="menuId" data-state-preference-name="menuBoardGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Permissions" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
table = $("#menuBoards").DataTable({
"language": dataTablesLanguage,
"lengthMenu": [10, 25, 50, 100, 250, 500],
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
dataType: 'json',
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("menuBoard.search") }}",
"data": function (d) {
$.extend(d, $("#menuBoards").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "menuId", responsivePriority: 2},
{
"data": "name",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"data": "description",
responsivePriority: 2,
"render": dataTableSpacingPreformatted
},
{
"data": "code", responsivePriority: 3
},
{
"name": "modifiedDt",
"data": function (data) {
return moment.unix(data.modifiedDt).format(jsDateFormat);
}
},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#menuBoards_wrapper').find('.col-md-6').eq(1));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
</script>
{% endblock %}

View File

@@ -1,121 +0,0 @@
{#
/*
* OTS Signs Theme - Module Page
* Based on Xibo CMS module-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Modules"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Modules" %}</h1>
<p class="text-muted">{% trans "Manage installed modules." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Modules" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.input('name', title) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="modules" class="table table-striped" data-state-preference-name="moduleGrid">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Library Media" %}</th>
<th>{% trans "Default Duration" %}</th>
<th>{% trans "Preview Enabled" %}</th>
<th title="{% trans "Can this module be assigned to a Layout?" %}">{% trans "Assignable" %}</th>
<th>{% trans "Enabled" %}</th>
<th>{% trans "Errors" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $('#modules').DataTable({
language: dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: false,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
order: [[ 0, 'asc']],
ajax: {
url: '{{ url_for("module.search") }}',
data: function (d) {
$.extend(d, $('#modules').closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
columns: [
{ "data": "name" , responsivePriority: 2},
{ "data": "description" },
{ "data": "regionSpecific", "render": dataTableTickCrossInverseColumn },
{ "data": "defaultDuration" },
{ "data": "previewEnabled", "render": dataTableTickCrossColumn },
{ "data": "assignable", "render": dataTableTickCrossColumn },
{ "data": "enabled", "render": dataTableTickCrossColumn },
{ "data": "errors", "render": dataTableTickCrossColumn },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#modules_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function moduleEditFormOpen(dialog) {
var moduleSettings = $(dialog).data('extra')['settings'];
var $targetContainer = $(dialog).find('.form-module-configure-fields')
forms.createFields(moduleSettings, $targetContainer);
}
</script>
{% endblock %}

View File

@@ -1,167 +0,0 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{% trans "Page Not Found" %} | {{ theme.getThemeConfig("theme_title") }}</title>
<meta charset="utf-8">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta name="public-path" content="{{ theme.rootUri() }}"/>
<link rel="shortcut icon" href="{{ theme.uri("img/favicon.ico") }}" />
<script src="{{ theme.rootUri() }}dist/style.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<link href="{{ theme.uri("css/override.css") }}?{{ version }}" rel="stylesheet" media="screen">
<link href="{{ theme.uri("css/override-dark.css") }}?{{ version }}" rel="stylesheet" media="screen">
<style type="text/css" nonce="{{ cspNonce }}">
html, body {
height: 100%;
margin: 0;
padding: 0;
}
body {
background-color: #0f172a;
color: #f1f5f9;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
}
.error-page {
text-align: center;
padding: 40px 24px;
max-width: 520px;
width: 100%;
}
.error-logo {
height: 48px;
width: auto;
margin-bottom: 32px;
}
.error-code {
font-size: 6rem;
font-weight: 700;
line-height: 1;
color: #e87800;
margin: 0 0 8px;
}
.error-title {
font-size: 1.5rem;
font-weight: 600;
color: #ffffff;
margin: 0 0 12px;
}
.error-message {
font-size: 0.95rem;
color: #94a3b8;
margin: 0 0 32px;
line-height: 1.6;
}
.error-actions {
display: flex;
gap: 12px;
justify-content: center;
flex-wrap: wrap;
}
.btn-home {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background-color: #e87800;
color: #ffffff;
text-decoration: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
transition: background-color 0.2s;
}
.btn-home:hover {
background-color: #c46500;
color: #ffffff;
text-decoration: none;
}
.btn-back {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 24px;
background-color: rgba(255, 255, 255, 0.08);
color: #e2e8f0;
text-decoration: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
border: 1px solid rgba(255, 255, 255, 0.12);
cursor: pointer;
transition: background-color 0.2s;
}
.btn-back:hover {
background-color: rgba(255, 255, 255, 0.14);
color: #ffffff;
text-decoration: none;
}
.redirect-notice {
margin-top: 28px;
font-size: 0.8rem;
color: #64748b;
}
.redirect-notice span {
color: #e87800;
font-weight: 600;
}
.error-divider {
width: 48px;
height: 3px;
background: #e87800;
border-radius: 2px;
margin: 16px auto 24px;
}
</style>
</head>
<body>
<div class="error-page" role="main">
<a href="{{ homeUrl }}">
<img class="error-logo" src="{{ theme.uri("img/xibologo.png") }}" alt="OTS Signs">
</a>
<p class="error-code" aria-label="Error 404">404</p>
<div class="error-divider" aria-hidden="true"></div>
<h1 class="error-title">{% trans "Page Not Found" %}</h1>
<p class="error-message">
{% trans "Sorry, we couldn't find the page you were looking for." %}
{% trans "It may have been moved, renamed, or it may not exist." %}
</p>
<div class="error-actions">
<a class="btn-home" href="{{ homeUrl }}">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path d="M8.354 1.146a.5.5 0 0 0-.708 0l-6 6A.5.5 0 0 0 1.5 7.5v7a.5.5 0 0 0 .5.5h4.5a.5.5 0 0 0 .5-.5v-4h2v4a.5.5 0 0 0 .5.5H14a.5.5 0 0 0 .5-.5v-7a.5.5 0 0 0-.146-.354L8.354 1.146z"/></svg>
{% trans "Go to Dashboard" %}
</a>
<button class="btn-back" onclick="history.back()" type="button">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" viewBox="0 0 16 16" aria-hidden="true"><path fill-rule="evenodd" d="M15 8a.5.5 0 0 0-.5-.5H2.707l3.147-3.146a.5.5 0 1 0-.708-.708l-4 4a.5.5 0 0 0 0 .708l4 4a.5.5 0 0 0 .708-.708L2.707 8.5H14.5A.5.5 0 0 0 15 8z"/></svg>
{% trans "Go Back" %}
</button>
</div>
<p class="redirect-notice" id="redirect-notice" aria-live="polite">
{% trans "Redirecting to dashboard in" %} <span id="countdown">10</span> {% trans "seconds" %}&hellip;
</p>
</div>
<script src="{{ theme.rootUri() }}dist/vendor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script type="text/javascript" nonce="{{ cspNonce }}">
(function () {
var homeUrl = {{ homeUrl | json_encode | raw }};
var seconds = 10;
var el = document.getElementById('countdown');
var interval = setInterval(function () {
seconds -= 1;
if (el) el.textContent = seconds;
if (seconds <= 0) {
clearInterval(interval);
window.location.replace(homeUrl);
}
}, 1000);
}());
</script>
</body>
</html>

File diff suppressed because it is too large Load Diff

View File

@@ -1,27 +0,0 @@
{#
Reusable dashboard card partial.
Usage (embed to allow overriding the `body` block):
{% embed 'custom/otssignange/views/partials/_dashboard-card.twig' with {'classes':'ots-displays-card'} %}
{% block body %}
... inner content ...
{% endblock %}
{% endembed %}
Optional blocks: badge, description, actions
These are additive — existing embeds that only use `body` are unaffected.
#}
<div class="dashboard-card {{ classes|default('') }}" {% if id is defined %}id="{{ id }}"{% endif %}>
{% if title is defined and title %}
<div class="dashboard-card-header">
{{ title|raw }}
{% block badge %}{% endblock %}
</div>
{% endif %}
<div class="dashboard-card-body">
{% block body %}{% endblock %}
{% block description %}{% endblock %}
</div>
{% block actions %}{% endblock %}
</div>

View File

@@ -1,197 +0,0 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Player Versions" %}</h1>
<p class="text-muted">{% trans "Manage player software versions and downloads." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="playerSoftwareView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Player Versions" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Type" %}{% endset %}
{{ inline.dropdown("playerType", "single", title, "", [{"type": none, "typeShow": none}]|merge(types), "type", "typeShow") }}
{% set title %}{% trans "Version" %}{% endset %}
{{ inline.dropdown("playerVersion", "single", title, "", [{"version": none, "version": none}]|merge(versions), "version", "version") }}
{% set title %}{% trans "Code" %}{% endset %}
{{ inline.input("playerCode", title) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.featureEnabled("playersoftware.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn" href="#" id="playerSoftwareUploadForm" title="{% trans "Upload a new Player Software file" %}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="playerSoftwareItems" class="table table-striped" data-state-preference-name="playerSoftwareGrid">
<thead>
<tr>
<th>{% trans "Version ID" %}</th>
<th>{% trans "Player Version Name" %}</th>
<th>{% trans "Type" %}</th>
<th>{% trans "Version" %}</th>
<th>{% trans "Code" %}</th>
<th>{% trans "File Name" %}</th>
<th>{% trans "Size" %}</th>
<th>{% trans "Created At" %}</th>
<th>{% trans "Modified At" %}</th>
<th>{% trans "Modified By" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table;
$(document).ready(function() {
table = $("#playerSoftwareItems").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[2, "asc"]],
ajax: {
"url": "{{ url_for("playersoftware.search") }}",
"data": function (d) {
$.extend(d, $("#playerSoftwareItems").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "versionId", responsivePriority: 2},
{"data": "playerShowVersion", responsivePriority: 2},
{"data": "type", responsivePriority: 2},
{"data": "version", responsivePriority: 2},
{"data": "code", responsivePriority: 2},
{"data": "fileName", responsivePriority: 4},
{
"name": "size",
responsivePriority: 3,
"data": null,
"render": {"_": "size", "display": "fileSizeFormatted", "sort": "size"}
},
{"data": "createdAt", responsivePriority: 6, visible: false},
{"data": "modifiedAt", responsivePriority: 6, visible: false},
{"data": "modifiedBy", responsivePriority: 6, visible: false},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
],
createdRow: function (row, data, index) {
if (data.version === "" || data.version === null || data.code === 0) {
$(row).addClass('table-danger');
$(row).attr('Title', "{{ "Please set Player Software Version"|trans }}");
}
},
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#playerSoftwareItems_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
$("#playerSoftwareUploadForm").click(function(e) {
e.preventDefault();
openUploadForm({
url: "{{ url_for("playersoftware.add") }}",
title: "{% trans "Upload Version" %}",
videoImageCovers: false,
buttons: {
main: {
label: "{% trans "Done" %}",
className: "btn-primary btn-bb-main",
callback: function () {
table.ajax.reload();
XiboDialogClose();
}
}
},
templateOptions: {
includeTagsInput: false,
multi: false,
trans: {
addFiles: "{% trans "Add files" %}",
startUpload: "{% trans "Start upload" %}",
cancelUpload: "{% trans "Cancel upload" %}",
processing: "{% trans "Processing..." %}"
},
upload: {
maxSize: {{ libraryUpload.maxSize }},
maxSizeMessage: "{{ libraryUpload.maxSizeMessage }}",
validExt: "{{ validExt }}"
},
updateInAllChecked: false,
deleteOldRevisionsChecked: false,
folderSelector: false
}
});
});
</script>
{% endblock %}

View File

@@ -1,551 +0,0 @@
{#
* Copyright (C) 2021 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Playlists"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Playlists" %}</h1>
<p class="text-muted">{% trans "Create and manage content playlists." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="playlistView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Playlists" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="general-filter">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{% set attributes = [
{ name: "data-live-search", value: "true" },
{ name: "data-selected-text-format", value: "count > 4" }
] %}
{% set title %}{% trans "Owner" %}{% endset %}
{% set helpText %}{% trans "Show items owned by the selected User." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("user.search") },
{ name: "data-search-term", value: "userName" },
{ name: "data-search-term-tags", value: "tags" },
{ name: "data-id-property", value: "userId" },
{ name: "data-text-property", value: "userName" },
{ name: "data-initial-key", value: "userId" },
] %}
{{ inline.dropdown("userId", "single", title, "", null, "userId", "userName", helpText, "pagedSelect", "", "", "", attributes) }}
{% set title %}{% trans "Owner User Group" %}{% endset %}
{% set helpText %}{% trans "Show items owned by users in the selected User Group." %}{% endset %}
{% set attributes = [
{ name: "data-width", value: "100%" },
{ name: "data-allow-clear", value: "true" },
{ name: "data-placeholder--id", value: null },
{ name: "data-placeholder--value", value: "" },
{ name: "data-search-url", value: url_for("group.search") },
{ name: "data-search-term", value: "group" },
{ name: "data-id-property", value: "groupId" },
{ name: "data-text-property", value: "group" },
{ name: "data-initial-key", value: "userGroupId" },
] %}
{{ inline.dropdown("ownerUserGroupId", "single", title, "", null, "groupId", "group", helpText, "pagedSelect", "", "", "", attributes) }}
{{ inline.hidden("folderId") }}
{% set title %}{% trans "Layout ID" %}{% endset %}
{{ inline.number("layoutId", title, layoutId) }}
</div>
<div class="tab-pane" id="advanced-filter">
{% set title %}{% trans "Show" %}{% endset %}
{% set values = [{id: 1, value: "All"}, {id: 2, value: "Only Used"}, {id: 3, value: "Only Unused"}] %}
{{ inline.dropdown("playlistStatusId", "single", title, 1, values, "id", "value") }}
{% if currentUser.featureEnabled("library.view") %}
{% set title %}{% trans "Media" %}{% endset %}
{{ inline.input("mediaLike", title) }}
{% endif %}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("playlist.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add Playlist" %}" href="{{ url_for("playlist.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="playlists" class="table table-striped" data-content-type="playlist"
data-content-id-name="playlistId" data-state-preference-name="playlistGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Duration" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Dynamic?" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Sharing" %}</th>
<th>{% trans "Created" %}</th>
<th>{% trans "Modified" %}</th>
<th>{% trans "Stats?" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<div id="dummyLayout" style="display:none"></div>
<div id="editor-container"></div>
<div class="loading-overlay">
<i class="fa fa-spinner fa-spin loading-icon"></i>
</div>
{% endblock %}
{% block javaScript %}
{# Add common files #}
{% include "editorTranslations.twig" %}
{% include "editorVars.twig" %}
<script src="{{ theme.rootUri() }}dist/playlistEditor.bundle.min.js?v={{ version }}&rev={{ revision }}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/codeEditor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/wysiwygEditor.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/editorCommon.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script type="text/javascript" nonce="{{ cspNonce }}">
{# Custom translations #}
{% autoescape "js" %}
{# Insert custom translations here #}
{% endautoescape %}
var table;
$(document).ready(function () {
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
// Create ourselves a little hidden layout for preview sizing, etc
$("#dummyLayout").html('<div id="layout" data-background-color="#000000" style="background-color: #000000" designer_scale="1"><div id="region_-1" zindex="1" tip_scale="1" designer_scale="1" width="800" height="450"></div></div>');
// Configure the DataTable
table = $("#playlists").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
"lengthMenu": [10, 25, 50, 100, 250, 500],
serverSide: true,
stateSave: true,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("playlist.search") }}",
"data": function (d) {
$.extend(d, $("#playlists").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "playlistId", responsivePriority: 2},
{
"data": "name",
responsivePriority: 3,
"render": dataTableSpacingPreformatted
},
{
"data": "duration",
responsivePriority: 3,
"render": function (data, type, row) {
if (type !== "display" && type !== "export")
return data;
if (row.requiresDurationUpdate === 1) {
return '<span class="fa fa-clock-o" title="{{ "Changes have been made and we are recalculating this Playlists duration" }}"></span>';
} else if (row.requiresDurationUpdate !== 0) {
return moment().startOf("day").seconds(data).format("H:mm:ss") + ' <span class="fa fa-clock-o" title="{{ "This duration will be updated at " }}' + moment(row.requiresDurationUpdate, "X").format(jsDateFormat) + '"></span>';
}
return dataTableTimeFromSeconds(data, type, row);
}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
responsivePriority: 4,
"data": dataTableCreateTags
},{% endif %}
{"data": "isDynamic", "render": dataTableTickCrossColumn, responsivePriority: 4},
{"data": "owner", responsivePriority: 4},
{
"data": "groupsWithPermissions",
responsivePriority: 5,
"render": dataTableCreatePermissions
},
{
"data": "createdDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"data": "modifiedDt",
responsivePriority: 6,
"render": dataTableDateFromIso,
"visible": false
},
{
"name": "enableStat",
responsivePriority: 6,
"data": function (data) {
var icon = "";
if (data.enableStat == 'On')
icon = "fa-check";
else if (data.enableStat == 'Off')
icon = "fa-times";
else
icon = "fa-level-down";
return '<span class="fa ' + icon + '" title="' + (data.enableStatDescription) + '"></span>';
}
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', {form: $("#playlists").closest(".XiboGrid").find(".FilterDiv form")}, dataTableCreateTagEvents);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#playlists_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
// Playlist Add Form
// contains a grid on the populate tab
// hook up the grid
var mediaTable;
var nameFilter;
var tagFilter;
var exactTags;
var logicalOperator;
var logicalOperatorName;
var filterFolderId;
function playlistEditorFormOpen(formData) {
// Clear container
$('#editor-container').empty();
// Append form
$('#editor-container').append(formData.message);
}
function playlistFormOpen(dialog) {
mediaTable = null;
$(dialog).find("input[name=filterMediaName]").on("keyup", _.debounce(function () {
playlistFormPopulateMediaTable(dialog);
}, 500));
$(dialog).find("input[name=filterMediaTag], input[name=exactTags], select[name=logicalOperator], select[name=logicalOperatorName], select[name=filterFolderId]").on("change", function () {
playlistFormPopulateMediaTable(dialog);
});
// First time in there
playlistFormPopulateMediaTable(dialog);
// Run function to set the form submit behaviour
playlistAddFormOpen();
}
///
/// Playlist Usage Form
///
function usageFormOpen(dialog) {
// Displays tab
var usageTable = $("#usageReportTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("playlist.usage", {id:':id'}) }}".replace(":id", $("#usageReportTable").data().playlistId),
"data": function (dataDisplay) {
$.extend(dataDisplay, $(dialog).find("#usageReportForm").serializeObject());
return dataDisplay;
}
},
"columns": [
{"data": "displayId"},
{"data": "display"},
{"data": "description"}
]
});
usageTable.on('draw', dataTableDraw);
usageTable.on('processing.dt', dataTableProcessing);
// Layouts tab
var usageTableLayouts = $("#usageReportLayoutsTable").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("playlist.usage.layouts", {id:':id'}) }}".replace(":id", $("#usageReportLayoutsTable").data().playlistId)
},
"columns": [
{"data": "layoutId"},
{"data": "layout"},
{"data": "description"},
{
"orderable": false,
"data": dataTableButtonsColumn
}
]
});
usageTableLayouts.on('draw', dataTableDraw);
usageTableLayouts.on('processing.dt', dataTableProcessing);
}
function playlistFormPopulateMediaTable(dialog) {
nameFilter = $(dialog).find("input[name=filterMediaName]").val();
tagFilter = $(dialog).find("input[name=filterMediaTag]").val();
exactTags = $(dialog).find("input[name=exactTags]").is(':checked')
logicalOperator = $(dialog).find("select[name=logicalOperator]").val();
logicalOperatorName = $(dialog).find("select[name=logicalOperatorName]").val();
filterFolderId = $(dialog).find("select[name=filterFolderId]").val() ?? "";
if (nameFilter === "" && tagFilter === "" && filterFolderId === "") {
if (mediaTable != null) {
mediaTable.destroy();
mediaTable = null;
$("#playlistLibraryMedia tbody").empty();
}
return;
}
if (mediaTable != null) {
mediaTable.ajax.reload();
} else {
mediaTable = $("#playlistLibraryMedia").DataTable({
"language": dataTablesLanguage,
serverSide: true,
stateSave: true,
stateDuration: 0,
filter: false,
responsive: true,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("library.search") }}",
"data": function (d) {
$.extend(
d,
{
media: nameFilter,
tags: tagFilter,
folderId: filterFolderId,
assignable: 1,
exactTags: exactTags,
logicalOperator: logicalOperator,
logicalOperatorName: logicalOperatorName
}
);
}
},
"columns": [
{"data": "mediaId"},
{"data": "name"},
{"data": "mediaType"},
{% if currentUser.featureEnabled("tag.tagging") %}{"data": dataTableCreateTags},{% endif %}
{
"name": "duration",
"data": function (data, type) {
if (type !== "display")
return data.duration;
return moment().startOf("day").seconds(data.duration).format("H:mm:ss");
}
}
]
});
mediaTable.on('processing.dt', dataTableProcessing);
mediaTable.on('draw', {form: $(".playlistForm")}, dataTableCreateTagEvents);
}
}
function setEnableStatMultiSelectFormOpen(dialog) {
var $select = $('<select id="enableStat" name="enableStat" class="form-control">' +
'<option value="Off">{% trans %} Off {% endtrans %}</option>' +
'<option value="On">{% trans %} On {% endtrans %}</option>' +
'<option value="Inherit">{% trans %} Inherit {% endtrans %}</option>' +
'</select>');
$select.on('change', function () {
dialog.data().commitData = {enableStat: $(this).val()};
}).trigger('change');
$(dialog).find('.modal-body').append($select);
}
function playlistAddFormOpen() {
$("#playlistAddForm").off("submit").submit(function (e) {
e.preventDefault();
var form = $(this);
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
cache: false,
dataType: "json",
success: function (xhr, textStatus, error) {
XiboSubmitResponse(xhr, form);
if (xhr.success && xhr.data.isDynamic == 0) {
// Open the editor
openPlaylistEditorForm(xhr.id);
}
}
});
});
}
function openPlaylistEditorForm(playlistId) {
var requestPath = playlistEditorUrl;
// replace id if necessary/exists
requestPath = requestPath.replace(':id', playlistId);
$.ajax({
url: requestPath,
type: 'GET'
}).done(function (res) {
if (!res.success) {
// Login Form needed?
if (res.login) {
window.location.reload();
} else {
// Just an error we dont know about
if (res.message == undefined) {
console.error(res);
} else {
console.error(res.message);
}
}
} else {
// Clear container
$('#editor-container').empty();
// Append form
$('#editor-container').append(res.html);
}
}).fail(function (jqXHR, textStatus, errorThrown) {
// Output error to console
console.error(jqXHR, textStatus, errorThrown);
});
}
</script>
{% endblock %}

View File

@@ -1,132 +0,0 @@
{#
/**
* Copyright (C) 2020 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Resolutions"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Resolutions" %}</h1>
<p class="text-muted">{% trans "Manage display resolutions." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="resolutionView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Resolutions" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Enabled" %}{% endset %}
{% set option1 %}{% trans "Yes" %}{% endset %}
{% set option2 %}{% trans "No" %}{% endset %}
{% set values = [{id: 1, value: option1}, {id: 0, value: option2}] %}
{{ inline.dropdown("enabled", "single", title, 1, values, "id", "value") }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.featureEnabled("resolution.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new resolution for use on layouts" %}" href="{{ url_for("resolution.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="resolutions" class="table table-striped" data-state-preference-name="resolutionGrid">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Resolution" %}</th>
<th>{% trans "Width" %}</th>
<th>{% trans "Height" %}</th>
<th>{% trans "Enabled?" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
$(document).ready(function() {
var table = $("#resolutions").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[1, "asc"]],
ajax: {
url: "{{ url_for("resolution.search") }}",
data: function (d) {
$.extend(d, $("#resolutions").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "resolutionId", responsivePriority: 2},
{"data": "resolution"},
{"data": "width"},
{"data": "height"},
{"data": "enabled"},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#resolutions_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
</script>
{% endblock %}

View File

@@ -1,562 +0,0 @@
{#
/**
* Copyright (C) 2023 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% import "forms.twig" as forms %}
{% block title %}{{ "Schedule"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Schedule" %}</h1>
<p class="text-muted">{% trans "Schedule content to your displays." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="scheduleGridView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Schedule" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="schedule-filter">
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item"><a class="nav-link active" href="#general-filter" role="tab" data-toggle="tab" aria-selected="true"><span>{% trans "General" %}</span></a></li>
<li class="nav-item"><a class="nav-link" href="#advanced-filter" role="tab" data-toggle="tab" aria-selected="false"><span>{% trans "Advanced" %}</span></a></li>
</ul>
<form class="form-inline">
<div class="tab-content">
<div class="tab-pane active" id="general-filter" role="tabpanel">
{% set title %}{% trans "Range" %}{% endset %}
{% set range %}{% trans "Custom" %}{% endset %}
{% set day %}{% trans "Day" %}{% endset %}
{% set week %}{% trans "Week" %}{% endset %}
{% set month %}{% trans "Month" %}{% endset %}
{% set year %}{% trans "Year" %}{% endset %}
{% set options = [
{ name: "custom", range: range },
{ name: "day", range: day },
{ name: "week", range: week },
{ name: "month", range: month },
{ name: "year", range: year },
] %}
{{ inline.dropdown("range", "single", title, "month", options, "name", "range", "", "date-range-input") }}
{% set title %}{% trans 'From Date' %}{% endset %}
{{ inline.dateTime("fromDt", title, "", "", "custom-date-range d-none", "", "") }}
{% set title %}{% trans 'To Date' %}{% endset %}
{{ inline.dateTime("toDt", title, "", "", "custom-date-range d-none", "", "") }}
{% set title %}{% trans "Date Controls" %}{% endset %}
<div class="form-group mr-1 mb-1 controls-date-range">
<div class="control-label mr-1" title=""
accesskey="">{{ title }}</div>
<div class="controls-date-inputs">
<div class="inputgroup date" id="dateInput">
<span class="btn btn-outline-primary date-open-button" role="button">
<i class="fa fa-calendar"></i>
</span>
<input type="text" class="form-control" id="dateInputLink" data-input style="display:none;"/>
</div>
<div class="btn-group">
<button type="button" class="btn btn-secondary" data-calendar-nav="prev"><span class="fa fa-caret-left"></span> {% trans "Prev" %}</button>
<button type="button" class="btn btn-outline-secondary" data-calendar-nav="today">{% trans "Today" %}</button>
<button type="button" class="btn btn-secondary" data-calendar-nav="next">{% trans "Next" %} <span class="fa fa-caret-right"></span></button>
</div>
</div>
</div>
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans 'Event Type' %}{% endset %}
{{ inline.dropdown("eventTypeId", "single", title, "", [{eventTypeId: null, eventTypeName: "All"}]|merge(eventTypes), "eventTypeId", "eventTypeName") }}
{% set title %}{% trans "Layout / Campaign" %}{% endset %}
{% set helpText %}{% trans "Please select a Layout or Campaign for this Event to show" %}{% endset %}
<div class="form-group mr-1 mb-1">
<label class="control-label mr-1" for="campaignId" title=""
accesskey="">{{ title }}</label>
<select name="campaignId" id="campaignIdFilter" class="form-control"
data-search-url="{{ url_for("campaign.search") }}"
data-trans-campaigns="{% trans "Campaigns" %}"
data-trans-layouts="{% trans "Layouts" %}"
data-allow-clear="true"
data-width="100%"
title="{% trans "Layout / Campaign" %}"
data-placeholder="{% trans "Layout / Campaign" %}"
data-dropdownAutoWidth
>
</select>
</div>
{% set title %}{% trans "Displays" %}{% endset %}
<div class="form-group mr-1 mb-1 pagedSelect">
<label class="control-label mr-1" for="DisplayList" title=""
accesskey="">{{ title }}</label>
<select id="DisplayList" class="form-control" name="displaySpecificGroupIds[]"
data-width="100%"
data-placeholder="{% trans "Displays" %}"
data-search-url="{{ url_for("display.search") }}"
data-search-term="display"
data-id-property="displayGroupId"
data-text-property="display"
data-additional-property="displayGroupId"
data-allow-clear="true"
data-initial-key="displayGroupIds[]"
multiple>
</select>
</div>
{% set title %}{% trans "Display Groups" %}{% endset %}
<div class="form-group mr-2 mb-1 pagedSelect">
<label class="control-label mr-1" for="DisplayGroupList" title=""
accesskey="">{{ title }}</label>
<select id="DisplayGroupList" class="form-control" name="displayGroupIds[]"
data-width="100%"
data-placeholder="{% trans "Display Groups" %}"
data-search-url="{{ url_for("displayGroup.search") }}"
data-search-term="displayGroup"
data-id-property="displayGroupId"
data-text-property="displayGroup"
data-allow-clear="true"
data-initial-key="displayGroupIds[]"
multiple>
</select>
</div>
</div>
<div class="tab-pane" id="advanced-filter" role="tabpanel">
{% set label %}{% trans "Direct Schedule?" %}{% endset %}
{% set title %}{% trans "Show only events scheduled directly on selected Displays/Groups" %}{% endset %}
<div class="form-group ml-2 mr-3 mb-1">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="directSchedule" name="directSchedule">
<label class="form-check-label" title="{{ title }}" for="directSchedule" accesskey="">{{ label }}</label>
</div>
</div>
{% set title %}{% trans "Only show schedules which appear on all filtered displays/groups?" %}{% endset %}
{% set label %}{% trans "Shared Schedule?" %}{% endset %}
<div class="form-group ml-2 mr-3 mb-1">
<div class="form-check">
<input title="{{ title }}" class="form-check-input" type="checkbox" id="sharedSchedule" name="sharedSchedule">
<label class="form-check-label" title="{{ title }}" for="sharedSchedule" accesskey="">{{ label }}</label>
</div>
</div>
{% set title %}{% trans 'Geo Aware?' %}{% endset %}
{% set options = [
{ id: null, name: "Both"|trans },
{ id: 0, name: "No"|trans },
{ id: 1, name: "Yes"|trans }
] %}
{{ inline.dropdown("geoAware", "single", title, "both", options, "id", "name") }}
{% set title %}{% trans 'Recurring?' %}{% endset %}
{% set options = [
{ id: null, name: "Both" },
{ id: 0, name: "No"|trans },
{ id: 1, name: "Yes"|trans }
] %}
{{ inline.dropdown("recurring", "single", title, "both", options, "id", "name") }}
</div>
</div>
</form>
</div>
</div>
</div>
<div class="XiboSchedule card content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.featureEnabled("schedule.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Scheduled event" %}" href="{{ url_for("schedule.add.form") }}"><i class="fa fa-plus" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<div class="card-header">
<ul class="nav nav-tabs card-header-tabs">
<li class="nav-item">
<a class="schedule-nav grid-nav nav-link active" id="grid-tab" href="#grid-view"
data-schedule-view="grid"
role="tab"
data-toggle="tab"><span>{% trans "Grid" %}</span></a>
</li>
<li class="nav-item">
<a class="schedule-nav calendar-nav nav-link" id="calendar-tab" href="#calendar-view"
data-schedule-view="calendar"
data-calendar-view="month"
role="tab"
data-toggle="tab"><span>{% trans "Calendar" %}</span></a>
</li>
</ul>
</div>
<div class="card-body">
<div class="xibo-calendar-header-container col-xl-12">
<div class="ots-calendar-nav">
<button type="button" class="ots-cal-arrow ots-cal-arrow-prev" id="ots-cal-prev" title="{% trans 'Previous' %}">
<i class="fa fa-chevron-left"></i>
</button>
<div class="xibo-calendar-header text-center">
<h1 class="page-header"></h1>
<div class="calendar-loading">
<span id="calendar-progress-table" class="fa fa-spin fa-cog"></span>
<span id="calendar-progress" class="fa fa-spin fa-cog"></span>
</div>
</div>
<button type="button" class="ots-cal-arrow ots-cal-arrow-next" id="ots-cal-next" title="{% trans 'Next' %}">
<i class="fa fa-chevron-right"></i>
</button>
</div>
</div>
<div class="tab-content">
<div class="tab-pane active" id="grid-view">
<div class="XiboData pt-3">
<table id="schedule-grid" class="table table-striped w-100"
data-state-preference-name="scheduleGrid">
<thead>
<tr>
<th>{% trans 'ID' %}</th>
<th></th>
<th>{% trans 'Event Type' %}</th>
<th>{% trans 'Name' %}</th>
<th>{% trans 'Start' %}</th>
<th>{% trans 'End' %}</th>
<th>{% trans 'Event' %}</th>
<th>{% trans 'Campaign ID' %}</th>
<th>{% trans 'Display Groups' %}</th>
<th>{% trans 'SoV' %}</th>
<th>{% trans 'Max Plays per Hour' %}</th>
<th>{% trans 'Geo Aware?' %}</th>
<th>{% trans 'Recurring?' %}</th>
<th>{% trans 'Recurrence Description' %}</th>
<th>{% trans 'Recurrence Type' %}</th>
<th>{% trans 'Recurrence Interval' %}</th>
<th>{% trans 'Recurrence Repeats On' %}</th>
<th>{% trans 'Recurrence End' %}</th>
<th>{% trans 'Priority?' %}</th>
<th>{% trans 'Criteria?' %}</th>
<th>{% trans 'Created On' %}</th>
<th>{% trans 'Updated On' %}</th>
<th>{% trans 'Modified By' %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
<div class="tab-pane" id="calendar-view">
{# ── OTS FullCalendar container (replaces legacy CalendarContainer visually) ── #}
<div id="ots-fullcalendar"></div>
{# Legacy container kept hidden so Xibo bundle doesn't error.
NOTE: id="Calendar" is intentionally omitted to prevent the Xibo bundle
from initialising the legacy calendar and throwing _loadEvents errors.
The data-agenda-link is preserved on CalendarContainer for our event-click handler. #}
<div class="row d-none" id="ots-legacy-calendar-row">
<div id="CalendarContainer"
data-agenda-link="{{ url_for("schedule.events", {id: ':id'}) }}"
data-calendar-type="{{ settings.CALENDAR_TYPE }}" class="col-sm-12"
data-default-lat="{{ defaultLat }}"
data-default-long="{{ defaultLong }}">
<div class="calendar-view" id="ots-legacy-calendar-stub"></div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<div class="cal-legend">
<ul>
<li><span class="fa fa-retweet" style="color:#6366f1"></span> {% trans "Always showing" %}</li>
<li><span class="fa fa-desktop" style="color:#0ea5e9"></span> {% trans "Single Display" %}</li>
<li><span class="fa fa-desktop" style="color:#10b981"></span> {% trans "Multi Display" %}</li>
<li><span class="fa fa-bullseye" style="color:#ef4444"></span> {% trans "Priority" %}</li>
<li><span class="fa fa-repeat" style="color:#8b5cf6"></span> {% trans "Recurring" %}</li>
<li><span class="fa fa-lock" style="color:#64748b"></span> {% trans "View Only" %}</li>
<li><span class="fa fa-wrench" style="color:#f59e0b"></span> {% trans "Command" %}</li>
<li><span class="fa fa-hand-paper" style="color:#f97316"></span> {% trans "Interrupt" %}</li>
<li><span class="fa fa-map-marker" style="color:#14b8a6"></span> {% trans "Geo Location" %}</li>
<li><span class="fa fa-paper-plane" style="color:#ec4899"></span> {% trans "Interactive Action" %}</li>
<li><span class="fa fa-refresh" style="color:#06b6d4"></span> {% trans "Synchronised" %}</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
{# Initialise JS variables #}
<script type="text/javascript" nonce="{{ cspNonce }}">
{# JS variables #}
var scheduleRecurrenceDeleteUrl = "{{ url_for("schedule.recurrence.delete.form", {id:':id'}) }}";
var layoutPreviewUrl = "{{ theme.rootUri() }}preview/layout/preview/:id";
var scheduleSearchUrl = "{{ url_for("schedule.search") }}";
var userAgendaViewEnabled = "{{ currentUser.featureEnabled('schedule.agenda') }}";
{# Custom translations #}
var schedulePageTrans = {
always: "{% trans "Always" %}",
adjustTimesofTimer: "{% trans "Adjust the times of this timer. To add or remove a day, use the Display Profile." %}",
daysOfTheWeek: {
monday: "{% trans "Monday" %}",
tuesday: "{% trans "Tuesday" %}",
wednesday: "{% trans "Wednesday" %}",
thursday: "{% trans "Thursday" %}",
friday: "{% trans "Friday" %}",
saturday: "{% trans "Saturday" %}",
sunday: "{% trans "Sunday" %}",
},
};
</script>
{# Add page source code bundle ( JS ) #}
<script src="{{ theme.rootUri() }}dist/leaflet.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
<script src="{{ theme.rootUri() }}dist/pages/schedule-page.bundle.min.js?v={{ version }}&rev={{revision}}" nonce="{{ cspNonce }}"></script>
{# ── FullCalendar v6 (inlined to bypass MIME issues with /custom/ paths) ── #}
<script nonce="{{ cspNonce }}">
{% include "fullcalendar-lib.twig" %}
</script>
{# ── OTS FullCalendar integration ── #}
<script nonce="{{ cspNonce }}">
$(function() {
// OTS calendar nav arrows drive FullCalendar directly (do NOT proxy to
// Xibo's data-calendar-nav buttons — those trigger the legacy calendar
// and cause _loadEvents / options.events errors).
$('#ots-cal-prev').on('click', function() {
if (otsCalendar) { otsCalendar.prev(); }
});
$('#ots-cal-next').on('click', function() {
if (otsCalendar) { otsCalendar.next(); }
});
// ── FullCalendar initialisation ──
var otsCalendar = null;
var calendarTabActive = false;
// Map Xibo event data to FullCalendar event object
function mapEvent(item) {
var title = item.schedule || item.campaign || item.command || item.layout || 'Event #' + item.eventId;
var cls = 'fc-event-single';
// Priority flag overrides everything
if (item.isPriority && parseInt(item.isPriority, 10) > 0) {
cls = 'fc-event-priority';
}
// Command events
else if (parseInt(item.eventTypeId, 10) === 2) {
cls = 'fc-event-command';
}
// Interrupt events
else if (parseInt(item.eventTypeId, 10) === 4) {
cls = 'fc-event-interrupt';
}
// Action events
else if (parseInt(item.eventTypeId, 10) === 6) {
cls = 'fc-event-action';
}
// "Always" daypart
else if (item.isAlways && parseInt(item.isAlways, 10) === 1) {
cls = 'fc-event-always';
}
// Geo-aware
else if (item.isGeoAware && parseInt(item.isGeoAware, 10) === 1) {
cls = 'fc-event-geo';
}
// Recurring
else if (item.recurrenceType && item.recurrenceType !== '' && item.recurrenceType !== 'null') {
cls = 'fc-event-recurring';
}
// Multi-display vs single
else if (item.displayGroups && item.displayGroups.length > 1) {
cls = 'fc-event-multi';
}
// Sync events
if (item.syncGroupId && parseInt(item.syncGroupId, 10) > 0) {
cls = 'fc-event-sync';
}
return {
id: item.eventId,
title: title,
start: item.fromDt,
end: item.toDt,
allDay: (item.isAlways && parseInt(item.isAlways, 10) === 1),
className: cls,
extendedProps: item
};
}
// Fetch events from Xibo schedule.search API
function fetchEvents(info, successCallback, failureCallback) {
var filterData = {
fromDt: info.startStr,
toDt: info.endStr
};
// Pull in active filter values
var $filter = $('#schedule-filter');
if ($filter.length) {
$filter.find(':input[name]').each(function() {
var $el = $(this);
var name = $el.attr('name');
var val = $el.val();
if (val && val !== '' && name !== 'fromDt' && name !== 'toDt') {
filterData[name] = val;
}
});
}
$.ajax({
url: scheduleSearchUrl,
type: 'GET',
dataType: 'json',
data: filterData,
success: function(response) {
var events = [];
var rows = response.data || response || [];
for (var i = 0; i < rows.length; i++) {
events.push(mapEvent(rows[i]));
}
successCallback(events);
},
error: function(xhr) {
console.warn('OTS: FullCalendar event fetch failed', xhr.status);
failureCallback(xhr);
}
});
}
// Handle event click → open Xibo edit form
function handleEventClick(info) {
var ev = info.event;
var props = ev.extendedProps || {};
if (props.eventId) {
var url = $('#CalendarContainer').data('agenda-link');
if (url) {
url = url.replace(':id', props.eventId);
XiboFormRender(url);
}
}
}
// Initialise FullCalendar on first Calendar-tab show
function initFullCalendar() {
if (otsCalendar) return;
var el = document.getElementById('ots-fullcalendar');
if (!el || typeof FullCalendar === 'undefined') return;
otsCalendar = new FullCalendar.Calendar(el, {
initialView: 'dayGridMonth',
headerToolbar: {
left: 'prev,next today',
center: 'title',
right: 'dayGridMonth,timeGridWeek,timeGridDay,listWeek'
},
buttonText: {
today: '{{ "Today"|trans }}',
month: '{{ "Month"|trans }}',
week: '{{ "Week"|trans }}',
day: '{{ "Day"|trans }}',
list: '{{ "List"|trans }}'
},
firstDay: 1,
nowIndicator: true,
navLinks: true,
editable: false,
selectable: false,
eventDisplay: 'block',
dayMaxEvents: 4,
height: 'auto',
events: fetchEvents,
eventClick: handleEventClick,
loading: function(isLoading) {
$('#calendar-progress').toggle(isLoading);
}
});
otsCalendar.render();
}
// Init when Calendar tab is shown
$('a[data-toggle="tab"][href="#calendar-view"]').on('shown.bs.tab', function() {
calendarTabActive = true;
initFullCalendar();
// Hide the Xibo calendar header (we use FC's built-in toolbar)
$('.xibo-calendar-header-container').hide();
});
// Re-show Xibo header when switching back to Grid; suspend FC interception
$('a[data-toggle="tab"][href="#grid-view"]').on('shown.bs.tab', function() {
$('.xibo-calendar-header-container').show();
calendarTabActive = false;
});
// Intercept Xibo's data-calendar-nav buttons when calendar tab is active
// and drive FullCalendar directly, preventing the legacy calendar from being invoked.
$(document).on('click', '[data-calendar-nav]', function(e) {
if (!calendarTabActive || !otsCalendar) return; // Grid tab active — let Xibo handle it
e.stopImmediatePropagation();
var nav = $(this).data('calendar-nav');
if (nav === 'prev') { otsCalendar.prev(); }
else if (nav === 'next') { otsCalendar.next(); }
else if (nav === 'today') { otsCalendar.today(); }
});
// Refetch events when filter changes
$('#schedule-filter').on('change', ':input', function() {
if (otsCalendar) {
otsCalendar.refetchEvents();
}
});
// If Calendar tab is active by default (URL hash), init immediately
if (window.location.hash === '#calendar-view' || $('#calendar-tab').hasClass('active')) {
calendarTabActive = true;
setTimeout(initFullCalendar, 100);
}
});
</script>
{% endblock %}

File diff suppressed because it is too large Load Diff

View File

@@ -1,187 +0,0 @@
{#
/**
* Copyright (C) 2024 Xibo Signage Ltd
*
* Xibo - Digital Signage - https://xibosignage.com
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Sync Groups"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Sync Groups" %}</h1>
<p class="text-muted">{% trans "Create and manage synchronized Display groups." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="syncGroupGridView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Sync Groups" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.input("syncGroupId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('name', title) }}
{% set title %}{% trans "Lead Display ID" %}{% endset %}
{{ inline.input("leadDisplayId", title) }}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("display.syncAdd") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Sync Group" %}" href="{{ url_for("syncgroup.form.add") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="syncgroups" class="table table-striped" data-content-type="syncGroup" data-content-id-name="syncGroupId" data-state-preference-name="syncGroupGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Created Date" %}</th>
<th>{% trans "Modified Date" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Modified By" %}</th>
<th>{% trans "Publisher Port" %}</th>
<th>{% trans "Switch Delay" %}</th>
<th>{% trans "Video Pause Delay" %}</th>
<th>{% trans "Lead Display" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
let syncGroupTable;
$(document).ready(function() {
syncGroupTable = $("#syncgroups").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
"filter": false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("syncgroup.search") }}",
"data": function(d) {
$.extend(d, $("#syncgroups").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "syncGroupId", responsivePriority: 2 },
{ "data": "name", responsivePriority: 1 },
{ "data": "createdDt", responsivePriority: 2 },
{ "data": "modifiedDt", responsivePriority: 2 },
{ "data": "owner", responsivePriority: 3 },
{ "data": "modifiedByName", responsivePriority: 4 },
{ "data": "syncPublisherPort", responsivePriority: 3 },
{ "data": "syncSwitchDelay", responsivePriority: 3 },
{ "data": "syncVideoPauseDelay", responsivePriority: 3 },
{ "data": "leadDisplay", responsivePriority: 3 },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
syncGroupTable.on('draw', dataTableDraw);
syncGroupTable.on('processing.dt', dataTableProcessing);
dataTableAddButtons(syncGroupTable, $('#syncgroups_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
syncGroupTable.ajax.reload();
});
});
</script>
{% endblock %}
{% block javaScriptTemplates %}
{{ parent() }}
{% verbatim %}
<script type="text/x-handlebars-template" id="template-display-group-multi-delete-checkbox">
<div class="form-group row">
<div class="offset-sm-2 col-sm-10 mt-4">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="checkbox-confirmDelete" name="confirmDelete">
<label class="form-check-label" for="checkbox-confirmDelete">
{% endverbatim %}{{ "Are you sure you want to delete?"|trans }}{% verbatim %}
</label>
</div>
<small class="form-text text-muted">{% endverbatim %}{{ "Check to confirm deletion of the selected records."|trans }}{% verbatim %}</small>
</div>
</div>
</script>
{% endverbatim %}
{% endblock %}

View File

@@ -1,171 +0,0 @@
{#
/*
* OTS Signs Theme - Tag Page
* Based on Xibo CMS tag-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Tags"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Tags" %}</h1>
<p class="text-muted">{% trans "Manage content tags." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="tagView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Tags" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "ID" %}{% endset %}
{{ inline.number("tagId", title) }}
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('tag', title) }}
{% set title %}{% trans "Show System tags?" %}{% endset %}
{{ inline.checkbox("isSystem", title, 0) }}
{% set title %}{% trans "Show only tags with values?" %}{% endset %}
{{ inline.checkbox("haveOptions", title, 0) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Tag" %}" href="{{ url_for("tag.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="tags" class="table table-striped">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "isRequired" %}</th>
<th>{% trans "Values" %}</th>
<th></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#tags").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "desc"]],
ajax: {
"url": "{{ url_for("tag.search") }}",
"data": function(d) {
$.extend(d, $("#tags").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "tagId", responsivePriority: 2 },
{ "data": "tag", responsivePriority: 2 },
{
"data": "isRequired",
responsivePriority: 3,
"render": function (data, type, row) {
if (type != "display") {
return data;
}
var icon = "";
if (data == 1)
icon = "fa-check";
else if (data == 0)
icon = "fa-times";
return "<span class='fa " + icon + "'></span>";
}
},
{
"data": "options",
responsivePriority: 3,
"render": function (data, type, row) {
if (type != "display") {
return data;
}
return JSON.parse(data);
}
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#tags_wrapper').find('.dataTables_buttons'), false);
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function usageFormOpen(dialog) {
const $tagUsageTable = $("#tagUsageTable");
var usageTable = $tagUsageTable.DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true, stateDuration: 0,
filter: false,
searchDelay: 3000,
responsive: true,
"order": [[1, "asc"]],
ajax: {
"url": "{{ url_for("tag.usage", {id: ':id'}) }}".replace(":id", $tagUsageTable.data().tagId),
"data": function(data) {
return data;
}
},
"columns": [
{ "data": "entityId"},
{ "data": "type"},
{ "data": "name" },
{ "data": "value" }
]
});
usageTable.on('draw', dataTableDraw);
usageTable.on('processing.dt', dataTableProcessing);
}
</script>
{% endblock %}

View File

@@ -1,177 +0,0 @@
{#
/*
* OTS Signs Theme - Task Page
* Based on Xibo CMS task-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Tasks"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Tasks" %}</h1>
<p class="text-muted">{% trans "Manage scheduled system tasks." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card" style="display:none;">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
</form>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if settings.TASK_CONFIG_LOCKED_CHECKB == 0 or settings.TASK_CONFIG_LOCKED_CHECKB == "Unchecked" %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" href="{{ url_for("task.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="tasks" class="table table-striped" data-state-preference-name="taskGrid">
<thead>
<tr>
<th>{% trans "ID" %}</th>
<th>{% trans "Name" %}</th>
<th>{% trans "Active" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Next Run" %}</th>
<th>{% trans "Run Now" %}</th>
<th>{% trans "Last Run" %}</th>
<th>{% trans "Last Status" %}</th>
<th>{% trans "Last Duration" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#tasks").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("task.search") }}",
"data": function(d) {
$.extend(d, $("#tasks").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "taskId" , responsivePriority: 2},
{ "data": "name" , responsivePriority: 2},
{
"data": "isActive",
responsivePriority: 2,
"render": dataTableTickCrossColumn
},
{
"data": "status",
"render": function (data, type, row) {
if (type !== "display")
return data;
var icon = "";
var title = "";
if (data === 1) {
if (moment(row.lastRunStartDt, "X").tz) {
title = "PID: " + row.pid + " (" + moment(row.lastRunStartDt, "X").tz(timezone).format(jsDateFormat) + ")";
} else {
title = "PID: " + row.pid + " (" + moment(row.lastRunStartDt, "X").format(jsDateFormat) + ")";
}
icon = "fa-cogs";
}
else if (data === 3) {
title = "Exit: " + row.lastRunExitCode;
icon = "fa-bug";
}
else if (data === 5) {
title = "Time out";
icon = "fa-hourglass-o";
}
else {
title = "";
icon = "fa-clock-o";
}
return '<span class="fa ' + icon + '" title="' + title + '"></span>';
}
},
{
"data": "nextRunDt",
"orderable": false,
"render": dataTableDateFromUnix
},
{
"data": "runNow",
"render": dataTableTickCrossColumn
},
{
"data": "lastRunDt",
"render": dataTableDateFromUnix
},
{
"data": "lastRunStatus",
"render": function (data, type, row) {
if (type !== "display")
return data;
var icon = "";
if (data === 4)
icon = "fa-check";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' +
((row.lastRunMessage === null) ? "" : row.lastRunMessage) + '"></span>';
}
},
{
"data": "lastRunDuration",
"render": function (data, type, row) {
if (type !== "display")
return data;
return (data === null) ? 0 : moment().startOf("day").seconds(data).format("H:mm:ss");
}
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#tasks_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
</script>
{% endblock %}

View File

@@ -1,290 +0,0 @@
{#
/**
* Copyright (C) 2022 Xibo Signage Ltd
*
* Xibo - Digital Signage - http://www.xibo.org.uk
*
* This file is part of Xibo.
*
* Xibo is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* any later version.
*
* Xibo is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Xibo. If not, see <http://www.gnu.org/licenses/>.
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Templates"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Templates" %}</h1>
<p class="text-muted">{% trans "Manage your reusable templates." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="templateView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Templates" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('template', title) }}
{% if currentUser.featureEnabled("tag.tagging") %}
{% set title %}{% trans "Tags" %}{% endset %}
{% set exactTagTitle %}{% trans "Exact match?" %}{% endset %}
{% set logicalOperatorTitle %}{% trans "When filtering by multiple Tags, which logical operator should be used?" %}{% endset %}
{% set helpText %}{% trans "A comma separated list of tags to filter by. Enter a tag|tag value to filter tags with values. Enter --no-tag to filter all items without tags. Enter - before a tag or tag value to exclude from results." %}{% endset %}
{{ inline.inputWithTags("tags", title, null, helpText, null, null, null, "exactTags", exactTagTitle, logicalOperatorTitle) }}
{% endif %}
{{ inline.hidden("folderId") }}
</form>
</div>
</div>
<div class="grid-with-folders-container ots-grid-with-folders">
<div class="grid-folder-tree-container p-3 content-card ots-folder-tree" id="grid-folder-filter">
<input id="jstree-search" class="form-control" type="text" placeholder="{% trans "Search" %}">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="folder-tree-clear-selection-button">
<label class="form-check-label" for="folder-tree-clear-selection-button" title="{% trans "Search in all folders" %}">{% trans "All Folders" %}</label>
</div>
<div class="folder-search-no-results d-none">
<p>{% trans 'No Folders matching the search term' %}</p>
</div>
<div id="container-folder-tree"></div>
</div>
<div id="datatable-container">
<div class="XiboData card py-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button type="button" id="folder-tree-select-folder-button" class="btn btn-sm btn-outline-secondary ots-toolbar-btn" title="{% trans "Open / Close Folder Search options" %}"><i class="fas fa-folder"></i></button>
<div id="breadcrumbs"></div>
{% if currentUser.featureEnabled("template.add") %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new Template and jump to the layout editor." %}" href="{{ url_for("template.add.form") }}"><i class="fa fa-plus-circle" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="templates" class="table table-striped" data-content-type="layout" data-content-id-name="layoutId" data-state-preference-name="templateGrid" style="width: 100%;">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Owner" %}</th>
<th>{% trans "Description" %}</th>
{% if currentUser.featureEnabled("tag.tagging") %}<th>{% trans "Tags" %}</th>{% endif %}
<th>{% trans "Orientation" %}</th>
<th>{% trans "Thumbnail" %}</th>
<th>{% trans "Sharing" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
{% if not currentUser.featureEnabled("folder.view") %}
disableFolders();
{% endif %}
var table = $("#templates").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 1, "asc"]],
ajax: {
"url": "{{ url_for("template.search") }}",
"data": function(d) {
$.extend(d, $("#templates").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "layout", responsivePriority: 2},
{
"name": "publishedStatus",
responsivePriority: 2,
"data": function (data, type) {
if (data.publishedDate != null) {
var now = moment();
var published = moment(data.publishedDate);
var differenceMinutes = published.diff(now, 'minutes');
var momentDifference = moment(now).to(published);
if (differenceMinutes < -5) {
return data.publishedStatus.concat(" - ", translations.publishedStatusFailed);
} else {
return data.publishedStatus.concat(" - ", translations.publishedStatusFuture + " " + momentDifference);
}
} else {
return data.publishedStatus;
}
}
},
{ "data": "owner", responsivePriority: 3},
{
"name": "description",
"data": null,
responsivePriority: 3,
"render": {"_": "description", "display": "descriptionWithMarkup", "sort": "description"}
},
{% if currentUser.featureEnabled("tag.tagging") %}{
"sortable": false,
"visible": false,
"data": dataTableCreateTags,
responsivePriority: 3
},{% endif %}
{ data: 'orientation', responsivePriority: 10, visible: false},
{
responsivePriority: 3,
data: 'thumbnail',
render: function (data, type, row) {
if (type !== 'display') {
return row.layoutId;
}
if (data) {
return '<a class="img-replace" data-toggle="lightbox" data-type="image" href="' + data + '">' +
'<img class="img-fluid" src="' + data + '" alt="{{ "Thumbnail"|trans }}" />' +
'</a>';
} else {
var addUrl = '{{ url_for("layout.thumbnail.add", {id: ":id"}) }}'.replace(':id', row.layoutId);
return '<a class="img-replace generate-layout-thumbnail" href="' + addUrl + '">' +
'<img class="img-fluid" src="{{ theme.uri("img/thumbs/placeholder.png") }}" alt="{{ "Add Thumbnail"|trans }}" />' +
'</a>';
}
return '';
},
sortable: false
},
{
"data": "groupsWithPermissions",
responsivePriority: 4,
"render": dataTableCreatePermissions
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('draw', { form: $("#templates").closest(".XiboGrid").find(".FilterDiv form") } ,dataTableCreateTagEvents);
table.on('draw', function(e, settings) {
$('#' + e.target.id + ' .generate-layout-thumbnail').on('click', function(e) {
e.preventDefault();
var $anchor = $(this);
$.ajax({
url: $anchor.attr('href'),
method: 'POST',
success: function() {
$anchor.find('img').attr('src', $anchor.attr('href'));
$anchor.removeClass('generate-layout-thumbnail').attr('data-toggle', 'lightbox');
}
});
});
});
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#templates_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
function templateFormOpen() {
if ($('#folder-tree-form-modal').length === 0) {
// compile tree folder modal and append it to Form
var folderTreeModal = templates['folder-tree'];
var treeConfig = {"container": "container-folder-form-tree", "modal": "folder-tree-form-modal"};
treeConfig.trans = translations.folderTree;
$("body").append(folderTreeModal(treeConfig));
$("#folder-tree-form-modal").on('hidden.bs.modal', function () {
// Fix for 2nd/overlay modal
$('.modal:visible').length && $(document.body).addClass('modal-open');
$(this).data('bs.modal', null);
});
}
// select current working folder if one is selected in the grid
if ($('#container-folder-tree').jstree("get_selected", true)[0] !== undefined) {
$('#templateAddForm' + ' #folderId').val($('#container-folder-tree').jstree("get_selected", true)[0].id);
}
initJsTreeAjax($('#folder-tree-form-modal').find('#container-folder-form-tree'), 'templateAddForm', true, 600);
$("#templateAddForm").submit(function(e) {
e.preventDefault();
var form = $(this);
var url = $(this).data().redirect;
$.ajax({
type: $(this).attr("method"),
url: $(this).attr("action"),
data: $(this).serialize(),
cache: false,
dataType:"json",
success: function(xhr, textStatus, error) {
XiboSubmitResponse(xhr, form);
if (xhr.success) {
// Reload the designer
XiboRedirect(url.replace(":id", xhr.id));
}
}
});
});
}
function layoutPublishFormOpen() {
// Nothing to do here, but we use the same form on the layout designer and have a callback registered there
}
function layoutEditFormSaved() {
// Nothing to do here.
}
</script>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -1,5 +0,0 @@
{#
OTS Signage Theme override
Optional dashboard message block included with ignore missing
#}

View File

@@ -1,22 +0,0 @@
{#
OTS Signage Theme - JavaScript and CSS injection
This file is auto-included by Xibo's base.twig at the end of the document
NOTE: CSS and JS are INLINED to bypass web server MIME type issues with /custom/ paths
This ensures all styles and scripts load regardless of web server routing configuration
#}
<!-- Theme CSS overrides - INLINED to bypass MIME type issues -->
<style nonce="{{ cspNonce }}">
{% include "override-styles.twig" %}
</style>
<!-- DataTables contrast fixes - INLINED to override core DataTables defaults -->
<style nonce="{{ cspNonce }}">
{% include "datatable-contrast.twig" %}
</style>
<!-- Theme JavaScript - INLINED to bypass MIME type issues -->
<script nonce="{{ cspNonce }}">
{% include "theme-scripts.twig" %}
</script>

File diff suppressed because it is too large Load Diff

View File

@@ -1,98 +0,0 @@
{#
/*
* OTS Signs Theme - Transition Page
* Based on Xibo CMS transition-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Transitions"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Transitions" %}</h1>
<p class="text-muted">{% trans "Manage display transitions." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card" style="display:none;">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
</form>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="transitions" class="table table-striped">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Has Duration" %}</th>
<th>{% trans "Has Direction" %}</th>
<th>{% trans "Enabled for In" %}</th>
<th>{% trans "Enabled for Out" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
var table = $("#transitions").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
filter: false,
searchDelay: 3000,
"order": [[ 0, "asc"]],
ajax: {
"url": "{{ url_for("transition.search") }}",
"data": function(d) {
$.extend(d, $("#transitions").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{ "data": "transition", responsivePriority: 2 },
{ "data": "hasDuration", "render": dataTableTickCrossColumn, responsivePriority: 3 },
{ "data": "hasDirection", "render": dataTableTickCrossColumn, responsivePriority: 3 },
{ "data": "availableAsIn", "render": dataTableTickCrossColumn, responsivePriority: 3 },
{ "data": "availableAsOut", "render": dataTableTickCrossColumn, responsivePriority: 3 },
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#transitions_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
</script>
{% endblock %}

View File

@@ -1,444 +0,0 @@
{#
/*
* OTS Signs Theme - User Page
* Based on Xibo CMS user-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "Users"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "Users" %}</h1>
<p class="text-muted">{% trans "Manage system users and permissions." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="usersView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter Users" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Username" %}{% endset %}
{{ inline.inputNameGrid('userName', title) }}
{% set title %}{% trans "User Type" %}{% endset %}
{{ inline.dropdown("userTypeId", "single", title, "", [{userTypeId:null, userType:""}]|merge(userTypes), "userTypeId", "userType") }}
{% set title %}{% trans "Retired" %}{% endset %}
{% set values = [{id: 1, value: "Yes"}, {id: 0, value: "No"}] %}
{{ inline.dropdown("retired", "single", title, 0, values, "id", "value") }}
{% set title %}{% trans "First Name" %}{% endset %}
{{ inline.input('firstName', title) }}
{% set title %}{% trans "Last Name" %}{% endset %}
{{ inline.input('lastName', title) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.isSuperAdmin() or (currentUser.isGroupAdmin() and currentUser.featureEnabled("users.add")) %}
{% if currentUser.getOptionValue("isAlwaysUseManualAddUserForm", 0) %}
{% set addUserFormUrl = url_for("user.add.form") %}
{% else %}
{% set addUserFormUrl = url_for("user.onboarding.form") %}
{% endif %}
<button id="user-add-button" class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new User" %}" href="{{ addUserFormUrl }}"><i class="fa fa-user-plus" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="users" class="table table-striped" data-state-preference-name="userGrid">
<thead>
<tr>
<th>{% trans "Username" %}</th>
<th>{% trans "Homepage" %}</th>
<th>{% trans "Home folder" %}</th>
<th>{% trans "Email" %}</th>
<th>{% trans "Library Quota" %}</th>
<th>{% trans "Last Login" %}</th>
<th>{% trans "Logged In?" %}</th>
<th>{% trans "Retired?" %}</th>
<th>{% trans "Two Factor" %}</th>
<th>{% trans "First Name" %}</th>
<th>{% trans "Last Name" %}</th>
<th>{% trans "Phone" %}</th>
<th>{% trans "Ref 1" %}</th>
<th>{% trans "Ref 2" %}</th>
<th>{% trans "Ref 3" %}</th>
<th>{% trans "Ref 4" %}</th>
<th>{% trans "Ref 5" %}</th>
<th class="rowMenu">{% trans "Row Menu" %}</th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
$(document).ready(function() {
var table = $("#users").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
responsive: true,
stateDuration: 0,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
searchDelay: 3000,
"order": [[0, "asc"]],
"filter": false,
ajax: {
url: "{{ url_for("user.search") }}",
"data": function (d) {
$.extend(d, $("#users").closest(".XiboGrid").find(".FilterDiv form").serializeObject());
}
},
"columns": [
{"data": "userName", responsivePriority: 2},
{
"data": "homePage",
"sortable": false,
responsivePriority: 3
},
{
data: 'homeFolder',
responsivePriority: 4
},
{"data": "email", responsivePriority: 3},
{
"name": "libraryQuota",
responsivePriority: 3,
"data": null,
"render": {"_": "libraryQuota", "display": "libraryQuotaFormatted", "sort": "libraryQuota"}
},
{"data": "lastAccessed", "visible": false, responsivePriority: 4},
{
"data": "loggedIn",
responsivePriority: 3,
"render": dataTableTickCrossColumn,
"visible": false,
"sortable": false
},
{
"data": "retired",
responsivePriority: 3,
"render": dataTableTickCrossColumn
},
{
"data": "twoFactorTypeId",
responsivePriority: 5,
"visible": false,
"render": function (data, type, row) {
if (type != "display")
return data;
var icon = "";
if (data == 1)
icon = "fa-envelope";
else if (data == 2)
icon = "fa-google";
else
icon = "fa-times";
return '<span class="fa ' + icon + '" title="' + (row.twoFactorDescription) + '"></span>';
}
},
{"data": "firstName", "visible": false, responsivePriority: 5},
{"data": "lastName", "visible": false, responsivePriority: 5},
{"data": "phone", "visible": false, responsivePriority: 5},
{"data": "ref1", "visible": false, responsivePriority: 5},
{"data": "ref2", "visible": false, responsivePriority: 5},
{"data": "ref3", "visible": false, responsivePriority: 5},
{"data": "ref4", "visible": false, responsivePriority: 5},
{"data": "ref5", "visible": false, responsivePriority: 5},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing)
dataTableAddButtons(table, $('#users_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
function userFormOpen(dialog) {
// Make a select2 from the home page select
var $userForm = $(dialog).find("form.UserForm");
var $groupId = $(dialog).find("select[name=groupId]");
var $userTypeId = $(dialog).find("select[name=userTypeId]");
var $select = $(dialog).find(".homepage-select");
$select.select2({
minimumResultsForSearch: Infinity,
ajax: {
url: $select.data("searchUrl"),
dataType: "json",
delay: 250,
data: function (params) {
return {
q: params.term,
page: params.page,
userId: $userForm.data().userId,
groupId: $groupId.val(),
userTypeId: $userTypeId.val(),
};
},
processResults: function (data) {
var results = [];
$.each(data.data, function(index, el) {
results.push({
"id": el.homepage,
"text": el.title,
"content": el.description
});
});
return {
results: results
};
}
},
templateResult: function(state) {
if (!state.content)
return state.text;
return $("<span>" + state.content + "</span>");
}
});
initFolderPanel(dialog, true);
// Validate form
var $userForm = $('.UserForm');
forms.validateForm(
$userForm,
$userForm.parents('.modal-body'),
{
submitHandler: function (form) {
var libraryQuotaField = $(form).find('input[name=libraryQuota]');
var libraryQuotaUnitsField = $(form).find('select[name=libraryQuotaUnits]');
var libraryQuota = libraryQuotaField.val();
if (libraryQuotaUnitsField.val() === 'mb') {
libraryQuota = libraryQuota * 1024;
} else if (libraryQuotaUnitsField.val() === 'gb') {
libraryQuota = libraryQuota * 1024 * 1024;
}
libraryQuotaField.prop('value', libraryQuota);
XiboFormSubmit(form);
},
},
);
}
function onboardingFormOpen(dialog) {
$(dialog).find('[data-toggle="popover"]').popover();
{% if currentUser.featureEnabled("folder.view") %}
initFolderPanel(dialog, false, true);
{% endif %}
var navListItems = $(dialog).find('div.setup-panel div a'),
allWells = $(dialog).find('.setup-content'),
stepWizard = $(dialog).find('.stepwizard');
navListItems.click(function (e) {
e.preventDefault();
var $target = $($(this).attr('href')),
$item = $(this);
if (!$item.attr('disabled')) {
navListItems
.removeClass('btn-success')
.addClass('btn-default');
$item.addClass('btn-success');
allWells.hide();
$target.show();
$target.find('input:eq(0)').focus();
stepWizard.data("active", $target.prop("id"))
if ($target.data("next") === "finished") {
$(dialog).find("#onboarding-steper-next-button").html("{{ "Save"|trans }}");
} else {
$(dialog).find("#onboarding-steper-next-button").html("{{ "Next"|trans }}")
}
}
});
$(dialog).find(".modal-footer")
.append($('<a class="btn btn-default">').html("{{ "Close"|trans }}")
.click(function(e) {
e.preventDefault();
XiboDialogClose();
}))
.append($('<a id="onboarding-steper-next-button" class="btn">').html("{{ "Next"|trans }}")
.addClass("btn-primary")
.click(function(e) {
e.preventDefault();
var steps = $(dialog).find(".stepwizard"),
curStep = $(dialog).find("#" + steps.data("active")),
curInputs = curStep.find("input[type='text'],input[type='url']"),
isValid = true;
if (curStep.data("next") === "finished") {
var $form = $(dialog).find("#userOnboardingForm");
$form.data("apply", true);
XiboFormSubmit($form, e, function(xhr) {
if (xhr.success && xhr.id) {
{% if currentUser.featureEnabled("folder.view") %}
var selected = $(dialog).find("#container-form-folder-tree").jstree("get_selected");
var rootIndex = selected.indexOf('1');
if (rootIndex > -1) {
selected.splice(rootIndex, 1);
}
var groupIds = {};
groupIds[xhr.data.groupId] = {
"view": 1,
"edit": 1
};
var permissionsUrl = "{{ url_for("user.permissions.multi", {entity: ":entity"}) }}";
$.ajax(permissionsUrl.replace(":entity", "Folder"), {
"method": "POST",
"data": {
"ids": selected.join(","),
"groupIds": groupIds
},
"error": function() {
toastr.error("{{ "Problem saving folder sharing, please check the User created." }}");
}
});
{% endif %}
XiboDialogClose();
}
});
} else if (curStep.data("next") === "onboarding-step-2" && $("input[name='groupId']:checked").val() === "manual") {
XiboDialogClose();
XiboFormRender("{{ url_for("user.add.form") }}");
} else {
var nextStepWizard = steps.find("a[href='#" + curStep.data("next") + "']");
$(dialog).find(".form-group").removeClass("has-error");
for (var i = 0; i < curInputs.length; i++) {
if (!curInputs[i].validity.valid) {
isValid = false;
$(curInputs[i]).closest(".form-group").addClass("has-error");
}
}
if (curStep.data("next") === "onboarding-step-2") {
var $userGroupSelected = $("input[name='groupId']:checked");
$(dialog).find("input[name=homePageId]").val($userGroupSelected.data("defaultHomepageId"));
}
if (isValid) {
nextStepWizard.removeAttr('disabled').trigger('click');
}
}
}));
}
function userHomeFolderFormOpen(dialog) {
initFolderPanel(dialog, true);
}
function userHomeFolderMultiselectFormOpen(dialog) {
var $input = $('<div id="container-form-folder-tree" class="card card-body bg-light"></div>');
var $helpText = $('<span class="help-block">{{ "Set a home folder to use as the default folder for new content."|trans }}</span>');
$(dialog).find('.modal-body').append($input);
$(dialog).find('.modal-body').append($helpText);
initFolderPanel(dialog, true);
}
function initFolderPanel(dialog, isHomeOnSelect = false, isHomeContext = false) {
var plugins = [];
if (!isHomeOnSelect) {
plugins.push('checkbox');
}
initJsTreeAjax(
'#container-form-folder-tree',
'user-add_edit-form',
true,
600,
function(tree, $container) {
if (!isHomeOnSelect) {
tree.disable_checkbox(1);
tree.disable_node(1);
}
$container.jstree('open_all');
},
function(data) {
if (isHomeOnSelect && data.action === 'select_node') {
$(dialog).find('input[name=homeFolderId]').val(data.node.id);
dialog.data().commitData = {homeFolderId: data.node.id};
}
},
function($node, items) {
if (isHomeContext) {
items['home'] = {
separator_before: false,
separator_after: false,
label: translations.folderTreeSetAsHome,
action: function () {
$(dialog).find('input[name=homeFolderId]').val($node.id);
}
}
}
return items;
},
plugins,
$(dialog).find('input[name=homeFolderId]').val()
);
$('.folder-tree-buttons').on('click', 'button', function(ev) {
const jsTree = $(dialog).find('#container-form-folder-tree').jstree(true);
if ($(ev.target).attr('id') === 'selectAllBtn') {
jsTree.select_all();
} else if ($(ev.target).attr('id') === 'selectNoneBtn') {
jsTree.deselect_all();
}
});
}
</script>
{% endblock %}

View File

@@ -1,194 +0,0 @@
{#
/*
* OTS Signs Theme - User Group Page
* Based on Xibo CMS usergroup-page.twig with OTS styling
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
{% block title %}{{ "User Groups"|trans }} | {% endblock %}
{% block actionMenu %}{% endblock %}
{% block pageContent %}
<div class="ots-static-page ots-displays-page">
<div class="page-header ots-page-header">
<h1>{% trans "User Groups" %}</h1>
<p class="text-muted">{% trans "Manage user groups and permissions." %}</p>
</div>
<div class="widget content-card ots-displays-card">
<div class="widget-body ots-displays-body">
<div class="XiboGrid" id="{{ random() }}" data-grid-name="userGroupView">
<div class="XiboFilter card mb-3 bg-light content-card ots-filter-card">
<div class="ots-filter-header">
<h3 class="ots-filter-title">{% trans "Filter User Groups" %}</h3>
<button type="button" class="ots-filter-toggle" id="ots-filter-collapse-btn" title="{% trans 'Toggle filter panel' %}">
<i class="fa fa-chevron-down"></i>
</button>
</div>
<div class="ots-filter-content collapsed" id="ots-filter-content">
<div class="FilterDiv card-body" id="Filter">
<form class="form-inline">
{% set title %}{% trans "Name" %}{% endset %}
{{ inline.inputNameGrid('userGroup', title) }}
</form>
</div>
</div>
</div>
<div class="XiboData card pt-3 content-card ots-table-card">
<div class="ots-table-toolbar">
{% if currentUser.isSuperAdmin() %}
<button class="btn btn-sm btn-success ots-toolbar-btn XiboFormButton" title="{% trans "Add a new User Group" %}" href="{{ url_for("group.add.form") }}"><i class="fa fa-users" aria-hidden="true"></i></button>
{% endif %}
<button class="btn btn-sm btn-primary ots-toolbar-btn" id="refreshGrid" title="{% trans "Refresh the Table" %}" href="#"><i class="fa fa-refresh" aria-hidden="true"></i></button>
</div>
<table id="userGroups" class="table table-striped" data-state-preference-name="userGroupGrid">
<thead>
<tr>
<th>{% trans "User Group" %}</th>
<th>{% trans "Description" %}</th>
<th>{% trans "Library Quota" %}</th>
<th>{% trans "Receive System Notifications?" %}</th>
<th>{% trans "Receive Display Notifications?" %}</th>
<th>{% trans "Receive Custom Notifications?" %}</th>
<th>{% trans "Receive DataSet Notifications?" %}</th>
<th>{% trans "Receive Layout Notifications?" %}</th>
<th>{% trans "Receive Library Notifications?" %}</th>
<th>{% trans "Receive Report Notifications?" %}</th>
<th>{% trans "Receive Schedule Notifications?" %}</th>
<th>{% trans "Is shown for Add User?" %}</th>
<th class="rowMenu"></th>
</tr>
</thead>
<tbody>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
$(document).ready(function() {
var table = $("#userGroups").DataTable({
"language": dataTablesLanguage,
dom: dataTablesTemplate,
serverSide: true,
stateSave: true,
stateDuration: 0,
responsive: true,
stateLoadCallback: dataTableStateLoadCallback,
stateSaveCallback: dataTableStateSaveCallback,
searchDelay: 3000,
filter: false,
order: [[0, 'asc']],
ajax: {
url: "{{ url_for('group.search') }}",
data: function (d) {
$.extend(d, $('#userGroups').closest('.XiboGrid').find('.FilterDiv form').serializeObject());
}
},
"columns": [
{data: 'group', render: dataTableSpacingPreformatted, responsivePriority: 2 },
{data: 'description', visible: false },
{
name: 'libraryQuota',
data: null,
render: {'_': 'libraryQuota', 'display': 'libraryQuotaFormatted', 'sort': 'libraryQuota'}
},
{
data: 'isSystemNotification',
render: dataTableTickCrossColumn
},
{
data: 'isDisplayNotification',
render: dataTableTickCrossColumn
},
{
data: 'isDataSetNotification',
render: dataTableTickCrossColumn,
visible: false
},
{
data: 'isLayoutNotification',
render: dataTableTickCrossColumn,
visible: false
},
{
data: 'isLibraryNotification',
render: dataTableTickCrossColumn,
visible: false
},
{
data: 'isReportNotification',
render: dataTableTickCrossColumn,
visible: false
},
{
data: 'isScheduleNotification',
render: dataTableTickCrossColumn,
visible: false
},
{
data: 'isCustomNotification',
render: dataTableTickCrossColumn,
visible: false
},
{
data: "isShownForAddUser",
render: dataTableTickCrossColumn
},
{
"orderable": false,
responsivePriority: 1,
"data": dataTableButtonsColumn
}
]
});
table.on('draw', dataTableDraw);
table.on('processing.dt', dataTableProcessing);
dataTableAddButtons(table, $('#userGroups_wrapper').find('.dataTables_buttons'));
$("#refreshGrid").click(function () {
table.ajax.reload();
});
});
function handleLibraryQuotaField(libraryQuotaField, libraryQuotaUnitsField) {
var libraryQuota = libraryQuotaField.val();
if (libraryQuotaUnitsField.val() === 'mb') {
libraryQuota = libraryQuota * 1024;
} else if (libraryQuotaUnitsField.val() === 'gb') {
libraryQuota = libraryQuota * 1024 * 1024;
}
libraryQuotaField.prop('value', libraryQuota);
}
function userGroupFormOpen() {
var $userGroupForm = $('.UserGroupForm');
forms.validateForm(
$userGroupForm,
$userGroupForm.parents('.modal-body'),
{
submitHandler: function (form) {
handleLibraryQuotaField(
$(form).find('input[name=libraryQuota]'),
$(form).find('select[name=libraryQuotaUnits]')
);
XiboFormSubmit(form);
},
},
);
}
</script>
{% endblock %}

View File

@@ -1,648 +0,0 @@
{#
/**
* Copyright (C) 2026 OTS Signs
*
* Welcome / onboarding page for OTS Signs.
*
* Overrides Xibo's default welcome-page.twig. All cards are rendered
* server-side in Twig — no dependency on the Xibo compiled JS bundle.
* Inline JS populates live stat counts via the existing fetchCount pattern.
*/
#}
{% extends "authed.twig" %}
{% block title %}{{ "Welcome"|trans }} | {% endblock %}
{% block pageContent %}
<style nonce="{{ cspNonce }}">
/* ── Welcome page layout ─────────────────────────────────────────────── */
.ots-welcome-page {
padding: 24px 32px 48px;
max-width: 1100px;
}
/* ── Hero ────────────────────────────────────────────────────────────── */
.ots-welcome-hero {
display: flex;
align-items: center;
gap: 40px;
padding: 40px 48px;
background: linear-gradient(135deg, var(--color-surface) 0%, #162035 100%);
border: 1px solid var(--color-border);
border-radius: 16px;
margin-bottom: 40px;
overflow: hidden;
position: relative;
}
.ots-welcome-hero::before {
content: '';
position: absolute;
top: -60px;
right: -60px;
width: 280px;
height: 280px;
background: radial-gradient(circle, rgba(232, 120, 0, 0.12) 0%, transparent 70%);
pointer-events: none;
}
.ots-welcome-hero-text {
flex: 1;
min-width: 0;
}
.ots-welcome-hero-text h1 {
font-size: 2rem;
font-weight: 700;
color: var(--color-text-primary);
margin: 0 0 12px;
line-height: 1.2;
}
.ots-welcome-hero-text h1 span {
color: #e87800;
}
.ots-welcome-hero-text p {
font-size: 1rem;
color: var(--color-text-tertiary);
margin: 0 0 24px;
max-width: 520px;
line-height: 1.6;
}
.ots-welcome-hero-actions {
display: flex;
gap: 12px;
flex-wrap: wrap;
}
.ots-welcome-btn-primary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: #e87800;
color: #fff;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
text-decoration: none;
transition: background 0.15s, transform 0.1s;
border: none;
}
.ots-welcome-btn-primary:hover,
.ots-welcome-btn-primary:focus {
background: #c96800;
color: #fff;
text-decoration: none;
transform: translateY(-1px);
}
.ots-welcome-btn-secondary {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 10px 20px;
background: transparent;
color: var(--color-text-secondary);
border: 1px solid var(--color-border);
border-radius: 8px;
font-size: 14px;
font-weight: 500;
text-decoration: none;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.ots-welcome-btn-secondary:hover,
.ots-welcome-btn-secondary:focus {
border-color: #e87800;
color: #e87800;
background: rgba(232, 120, 0, 0.06);
text-decoration: none;
}
.ots-welcome-hero-icon {
flex-shrink: 0;
width: 140px;
height: 140px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(232, 120, 0, 0.1);
border: 1px solid rgba(232, 120, 0, 0.2);
border-radius: 24px;
color: #e87800;
font-size: 64px;
}
/* ── Section heading ─────────────────────────────────────────────────── */
.ots-welcome-section-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.06em;
margin: 0 0 20px;
}
/* ── Step cards ──────────────────────────────────────────────────────── */
.ots-welcome-steps {
margin-bottom: 40px;
}
.ots-welcome-step-card {
display: flex;
align-items: flex-start;
gap: 24px;
padding: 24px 28px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
margin-bottom: 16px;
transition: border-color 0.15s, box-shadow 0.15s;
text-decoration: none;
color: inherit;
position: relative;
}
.ots-welcome-step-card:hover {
border-color: rgba(232, 120, 0, 0.35);
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
}
.ots-step-left {
display: flex;
align-items: center;
gap: 20px;
flex-shrink: 0;
}
.ots-step-num {
width: 28px;
height: 28px;
border-radius: 50%;
background: var(--color-surface-elevated);
border: 1px solid var(--color-border);
color: var(--color-text-tertiary);
font-size: 12px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.ots-step-icon {
width: 52px;
height: 52px;
border-radius: 12px;
display: flex;
align-items: center;
justify-content: center;
font-size: 22px;
flex-shrink: 0;
}
.ots-step-icon--green { background: rgba(16, 185, 129, 0.15); color: #10b981; }
.ots-step-icon--blue { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
.ots-step-icon--purple { background: rgba(124, 58, 237, 0.15); color: #7c3aed; }
.ots-step-icon--orange { background: rgba(232, 120, 0, 0.15); color: #e87800; }
.ots-step-body {
flex: 1;
min-width: 0;
}
.ots-step-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 4px;
}
.ots-step-desc {
font-size: 14px;
color: var(--color-text-tertiary);
margin: 0 0 12px;
line-height: 1.5;
}
.ots-step-links {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.ots-step-links a {
font-size: 13px;
font-weight: 500;
text-decoration: none;
padding: 5px 12px;
border-radius: 6px;
}
.ots-step-link-primary {
background: rgba(232, 120, 0, 0.15);
color: #e87800;
border: 1px solid rgba(232, 120, 0, 0.25);
}
.ots-step-link-primary:hover,
.ots-step-link-primary:focus {
background: rgba(232, 120, 0, 0.25);
color: #e87800;
text-decoration: none;
}
.ots-step-link-secondary {
color: var(--color-text-tertiary);
border: 1px solid var(--color-border);
}
.ots-step-link-secondary:hover,
.ots-step-link-secondary:focus {
color: var(--color-text-secondary);
border-color: var(--color-text-tertiary);
text-decoration: none;
}
.ots-step-stat {
flex-shrink: 0;
text-align: right;
display: flex;
flex-direction: column;
align-items: flex-end;
justify-content: center;
gap: 2px;
padding-left: 16px;
min-width: 64px;
}
.ots-step-stat-num {
font-size: 1.5rem;
font-weight: 700;
color: var(--color-text-primary);
line-height: 1;
}
.ots-step-stat-label {
font-size: 11px;
color: var(--color-text-tertiary);
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* ── Resource cards ──────────────────────────────────────────────────── */
.ots-welcome-resources {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
}
.ots-welcome-resource-card {
display: flex;
align-items: flex-start;
gap: 16px;
padding: 20px 22px;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: 12px;
transition: border-color 0.15s, box-shadow 0.15s;
text-decoration: none;
color: inherit;
}
.ots-welcome-resource-card:hover {
border-color: rgba(232, 120, 0, 0.35);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
text-decoration: none;
color: inherit;
}
.ots-resource-icon {
width: 44px;
height: 44px;
border-radius: 10px;
background: rgba(232, 120, 0, 0.12);
color: #e87800;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
flex-shrink: 0;
}
.ots-resource-body {
min-width: 0;
}
.ots-resource-title {
font-size: 14px;
font-weight: 600;
color: var(--color-text-primary);
margin: 0 0 4px;
}
.ots-resource-desc {
font-size: 13px;
color: var(--color-text-tertiary);
margin: 0;
line-height: 1.4;
}
/* ── Light mode overrides ────────────────────────────────────────────── */
.ots-light-mode .ots-welcome-hero {
background: linear-gradient(135deg, #f8fafc 0%, #f0f6ff 100%);
border-color: #e2e8f0;
}
.ots-light-mode .ots-welcome-step-card,
.ots-light-mode .ots-welcome-resource-card {
background: #fff;
border-color: #e2e8f0;
}
.ots-light-mode .ots-step-num {
background: #f1f5f9;
border-color: #e2e8f0;
color: #64748b;
}
/* ── Responsive ──────────────────────────────────────────────────────── */
@media (max-width: 768px) {
.ots-welcome-page {
padding: 16px 16px 40px;
}
.ots-welcome-hero {
flex-direction: column;
padding: 28px 24px;
gap: 24px;
}
.ots-welcome-hero-icon {
width: 80px;
height: 80px;
font-size: 36px;
border-radius: 16px;
align-self: flex-start;
}
.ots-welcome-hero-text h1 {
font-size: 1.5rem;
}
.ots-welcome-step-card {
flex-wrap: wrap;
gap: 16px;
}
.ots-step-stat {
text-align: left;
align-items: flex-start;
padding-left: 0;
padding-top: 8px;
border-top: 1px solid var(--color-border);
flex-direction: row;
gap: 8px;
min-width: 0;
width: 100%;
}
.ots-welcome-resources {
grid-template-columns: 1fr;
}
}
</style>
<div class="ots-welcome-page">
{# ── Hero ─────────────────────────────────────────────────────────── #}
{% set productName = theme.getThemeConfig('theme_title') %}
<div class="ots-welcome-hero">
<div class="ots-welcome-hero-text">
<h1>{% trans %}Welcome to <span>{{ productName }}</span>{% endtrans %}</h1>
<p>{% trans %}Your digital signage control centre. Connect your displays, upload content, design layouts, and schedule what plays — all from one place.{% endtrans %}</p>
<div class="ots-welcome-hero-actions">
<a href="{{ helpService.getLandingPage() }}" target="_blank" rel="noopener noreferrer" class="ots-welcome-btn-primary">
<i class="fa fa-book" aria-hidden="true"></i>
{% trans "View Documentation" %}
</a>
<a href="{{ url_for("home") }}" class="ots-welcome-btn-secondary">
<i class="fa fa-th-large" aria-hidden="true"></i>
{% trans "Go to Dashboard" %}
</a>
</div>
</div>
<div class="ots-welcome-hero-icon" aria-hidden="true">
<i class="fa fa-tv"></i>
</div>
</div>
{# ── Get Started steps ────────────────────────────────────────────── #}
<div class="ots-welcome-steps">
<p class="ots-welcome-section-title">{% trans "Get Started" %}</p>
{% if currentUser.featureEnabled("displays.view") %}
<div class="ots-welcome-step-card">
<div class="ots-step-left">
<div class="ots-step-num">1</div>
<div class="ots-step-icon ots-step-icon--green">
<i class="fa fa-desktop" aria-hidden="true"></i>
</div>
</div>
<div class="ots-step-body">
<div class="ots-step-title">{% trans "Connect a Display" %}</div>
<p class="ots-step-desc">{% trans %}Install the player app on a screen, then authorise it here. Once connected, your display is ready to receive scheduled content.{% endtrans %}</p>
<div class="ots-step-links">
<a href="{{ url_for("display.view") }}" class="ots-step-link-primary">
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Manage Displays" %}
</a>
<a href="{{ helpService.getLandingPage() }}displays.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Displays Guide" %}
</a>
</div>
</div>
<div class="ots-step-stat">
<span class="ots-step-stat-num" id="ots-wc-stat-displays">—</span>
<span class="ots-step-stat-label">{% trans "Displays" %}</span>
</div>
</div>
{% endif %}
{% if currentUser.featureEnabled("library.view") %}
<div class="ots-welcome-step-card">
<div class="ots-step-left">
<div class="ots-step-num">2</div>
<div class="ots-step-icon ots-step-icon--blue">
<i class="fa fa-image" aria-hidden="true"></i>
</div>
</div>
<div class="ots-step-body">
<div class="ots-step-title">{% trans "Upload Content" %}</div>
<p class="ots-step-desc">{% trans %}Add images, videos, and other media files to your library. Supported formats include JPEG, PNG, MP4 and more.{% endtrans %}</p>
<div class="ots-step-links">
<a href="{{ url_for("library.view") }}" class="ots-step-link-primary">
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Open Library" %}
</a>
<a href="{{ helpService.getLandingPage() }}media_library.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Library Guide" %}
</a>
</div>
</div>
<div class="ots-step-stat">
<span class="ots-step-stat-num" id="ots-wc-stat-media">—</span>
<span class="ots-step-stat-label">{% trans "Media Files" %}</span>
</div>
</div>
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
<div class="ots-welcome-step-card">
<div class="ots-step-left">
<div class="ots-step-num">3</div>
<div class="ots-step-icon ots-step-icon--purple">
<i class="fa fa-columns" aria-hidden="true"></i>
</div>
</div>
<div class="ots-step-body">
<div class="ots-step-title">{% trans "Design a Layout" %}</div>
<p class="ots-step-desc">{% trans %}Create multi-zone screen layouts using the visual editor. Combine images, videos, text, and data widgets into a polished design.{% endtrans %}</p>
<div class="ots-step-links">
<a href="{{ url_for("layout.view") }}" class="ots-step-link-primary">
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Manage Layouts" %}
</a>
<a href="{{ helpService.getLandingPage() }}layouts_editor.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Layout Editor Guide" %}
</a>
</div>
</div>
<div class="ots-step-stat">
<span class="ots-step-stat-num" id="ots-wc-stat-layouts">—</span>
<span class="ots-step-stat-label">{% trans "Layouts" %}</span>
</div>
</div>
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
<div class="ots-welcome-step-card">
<div class="ots-step-left">
<div class="ots-step-num">4</div>
<div class="ots-step-icon ots-step-icon--orange">
<i class="fa fa-calendar" aria-hidden="true"></i>
</div>
</div>
<div class="ots-step-body">
<div class="ots-step-title">{% trans "Schedule Content" %}</div>
<p class="ots-step-desc">{% trans %}Assign layouts and campaigns to displays on a timed schedule. Set start and end times, repeat rules, and priorities.{% endtrans %}</p>
<div class="ots-step-links">
<a href="{{ url_for("schedule.view") }}" class="ots-step-link-primary">
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Open Schedule" %}
</a>
<a href="{{ helpService.getLandingPage() }}displays_configuration.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Scheduling Guide" %}
</a>
</div>
</div>
<div class="ots-step-stat">
<span class="ots-step-stat-num" id="ots-wc-stat-schedules">—</span>
<span class="ots-step-stat-label">{% trans "Schedules" %}</span>
</div>
</div>
{% endif %}
</div>{# /ots-welcome-steps #}
{# ── Resources ────────────────────────────────────────────────────── #}
<p class="ots-welcome-section-title">{% trans "Resources" %}</p>
<div class="ots-welcome-resources">
<a href="{{ helpService.getLandingPage() }}" target="_blank" rel="noopener noreferrer" class="ots-welcome-resource-card">
<div class="ots-resource-icon" aria-hidden="true">
<i class="fa fa-book"></i>
</div>
<div class="ots-resource-body">
<div class="ots-resource-title">{% trans "User Manual" %}</div>
<p class="ots-resource-desc">{% trans "Step-by-step guides for every feature in OTS Signs." %}</p>
</div>
</a>
<a href="{{ theme.getSetting('SUPPORT_ADDRESS', 'https://ots-signs.com/support') }}" target="_blank" rel="noopener noreferrer" class="ots-welcome-resource-card">
<div class="ots-resource-icon" aria-hidden="true">
<i class="fa fa-life-ring"></i>
</div>
<div class="ots-resource-body">
<div class="ots-resource-title">{% trans "Support" %}</div>
<p class="ots-resource-desc">{% trans "Get help from the OTS Signs support team." %}</p>
</div>
</a>
{% if currentUser.isSuperAdmin() %}
<a href="{{ url_for("settings") }}" class="ots-welcome-resource-card">
<div class="ots-resource-icon" aria-hidden="true">
<i class="fa fa-cog"></i>
</div>
<div class="ots-resource-body">
<div class="ots-resource-title">{% trans "CMS Settings" %}</div>
<p class="ots-resource-desc">{% trans "Configure your CMS installation and storage options." %}</p>
</div>
</a>
{% endif %}
</div>{# /ots-welcome-resources #}
</div>{# /ots-welcome-page #}
{% endblock %}
{% block javaScript %}
<script type="text/javascript" nonce="{{ cspNonce }}">
(function () {
'use strict';
var $ = window.jQuery;
if (!$) return;
function fetchCount(url, elId) {
$.ajax({
url: url,
type: 'GET',
dataType: 'json',
data: { start: 0, length: 1 },
success: function (resp) {
var count = 0;
if (resp && typeof resp.recordsTotal !== 'undefined') {
count = resp.recordsTotal;
} else if (resp && Array.isArray(resp.data)) {
count = resp.data.length;
} else if (resp && typeof resp.total !== 'undefined') {
count = resp.total;
}
var el = document.getElementById(elId);
if (el) el.textContent = count.toLocaleString();
},
error: function () {
var el = document.getElementById(elId);
if (el) el.textContent = '—';
}
});
}
$(function () {
{% if currentUser.featureEnabled("displays.view") %}
fetchCount('{{ url_for("display.search") }}', 'ots-wc-stat-displays');
{% endif %}
{% if currentUser.featureEnabled("library.view") %}
fetchCount('{{ url_for("library.search") }}', 'ots-wc-stat-media');
{% endif %}
{% if currentUser.featureEnabled("layout.view") %}
fetchCount('{{ url_for("layout.search") }}', 'ots-wc-stat-layouts');
{% endif %}
{% if currentUser.featureEnabled("schedule.view") %}
fetchCount('{{ url_for("schedule.search") }}', 'ots-wc-stat-schedules');
{% endif %}
});
}());
</script>
{% endblock %}