Restructure

This commit is contained in:
Matt Batchelder
2026-02-11 21:07:22 -05:00
parent 0e4cb80297
commit e44a77b284
68 changed files with 0 additions and 3109 deletions

91
views/about-page.twig Normal file
View File

@@ -0,0 +1,91 @@
{#
/**
* 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 %}

51
views/about-text.twig Normal file
View File

@@ -0,0 +1,51 @@
{#
/**
* 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

@@ -0,0 +1,268 @@
{#
/*
* 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

@@ -0,0 +1,26 @@
{#
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 %}

441
views/authed-sidebar.twig Normal file
View File

@@ -0,0 +1,441 @@
{#
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">
<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" 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">
{% 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" 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">
{% 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" 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">
{% 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" 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">
{% 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" 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">
{% 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

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

472
views/authed-topbar.twig Normal file
View File

@@ -0,0 +1,472 @@
{#
/**
* 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"></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"></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"></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"></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"></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"></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"></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"></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

@@ -0,0 +1,51 @@
{#
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 %}

167
views/authed.twig Normal file
View File

@@ -0,0 +1,167 @@
{#
/**
* Copyright (C) 2020-2025 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 "base.twig" %}
{% block headContent %}
<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') {
// diagnostic
try { console.debug && console.debug('otsTheme:sidebarCollapsed early:', collapsed); } catch(e){}
// Add on <html> immediately; body may not be parsed yet
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'); } catch(e){}
} else {
try { console.debug && console.debug('otsTheme:sidebarCollapsed early: not set'); } catch(e){}
}
} 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>
{% endblock %}
{% block content %}
{% set horizontalNav = currentUser.getOptionValue("navigationMenuPosition", theme.getSetting("NAVIGATION_MENU_POSITION", "vertical")) == "horizontal" %}
{% if not hideNavigation %}
{% set hideNavigation = currentUser.getOptionValue("hideNavigation", "0") %}
{% endif %}
<div {% if hideNavigation == "0" and not horizontalNav and not forceHide %}id="page-wrapper"{% endif %} class="active">
{% if hideNavigation == "0" and not forceHide %}
{% if horizontalNav %}
<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 -->
<button type="button" class="navbar-toggler" data-toggle="collapse" data-target="#navbar-collapse-1" aria-controls="navbarNav" aria-expanded="false">
<span class="fa fa-bars"></span>
</button>
<!-- Collect the nav links, forms, and other content for toggling -->
<div class="navbar-collapse collapse justify-content-between" id="navbar-collapse-1">
{% include "authed-topbar.twig" %}
<ul class="nav navbar-nav navbar-right">
{% include "authed-theme-topbar.twig" ignore missing %}
{% if currentUser.featureEnabled("drawer") %}
{% include "authed-notification-drawer.twig" %}
{% endif %}
{% include "authed-user-menu.twig" %}
</ul>
</div><!-- /.navbar-collapse -->
</nav>
{% else %}
<div class="navbar-collapse navbar-collapse-side collapse" id="navbar-collapse-1">
{% include "authed-sidebar.twig" %}
</div>
{% 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 class="page-content">
<div class="row">
<div class="col-sm-12">
{% block actionMenu %}{% endblock %}
{% if settings.INSTANCE_SUSPENDED == "partial" %}
<div class="alert alert-warning">{{ "CMS suspended. Displays will show cached content. Please contact your administrator."|trans }}</div>
{% endif %}
{% block pageContent %}{% endblock %}
</div>
</div>
<div class="row">
<div class="col-sm-12">
{% block pageFooter %}{% endblock %}
</div>
</div>
</div>
</div>
</div>
{% set helpLinks = helpService.getLinksForPage(route) %}
{% set faultViewEnabled = currentUser.featureEnabled("fault.view") %}
{# Hide in mobile view (sm/<768px) #}
<div id="help-pane" class="d-none d-md-flex help-pane"
data-help-links="{{ helpLinks|json_encode }}"
data-url-help-landing-page={{ helpService.getLandingPage() }}
data-fault-view-enabled={{faultViewEnabled}}
data-fault-view-url={{ url_for("fault.view") }}
>
<div class="help-pane-container" style="display: none;">
</div>
<div class="help-pane-btn">
<i class="fas fa-question"></i>
</div>
</div>
{% endblock %}
{% block javaScriptTemplates %}
{# File upload templates and scripts #}
{% include "include-file-upload.twig" %}
{% endblock %}

183
views/campaign-page.twig Normal file
View File

@@ -0,0 +1,183 @@
{#
/**
* 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 %}

161
views/command-page.twig Normal file
View File

@@ -0,0 +1,161 @@
{#
/**
* 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

@@ -0,0 +1,461 @@
{#
/**
* 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>
{# ── Scheduling ────────────────────────────────────────────── #}
{% set scheduleCount = currentUser.featureEnabledCount(["schedule.view", "daypart.view"]) %}
{% if scheduleCount > 0 %}
<div class="icon-dash-section">
<h3 class="section-title"><i class="fa fa-calendar"></i> {% trans "Scheduling" %}</h3>
<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 event schedules" %}</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 slots" %}</span>
</div>
</a>
{% endif %}
</div>
</div>
{% endif %}
{# ── Design ────────────────────────────────────────────────── #}
{% set countViewable = currentUser.featureEnabledCount(["campaign.view", "layout.view", "template.view", "resolution.view"]) %}
{% if countViewable > 0 %}
<div class="icon-dash-section">
<h3 class="section-title"><i class="fa fa-paint-brush"></i> {% trans "Design" %}</h3>
<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 layouts" %}</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 templates" %}</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 resolution presets" %}</span>
</div>
</a>
{% endif %}
</div>
</div>
{% endif %}
{# ── Library ───────────────────────────────────────────────── #}
{% set countViewable = currentUser.featureEnabledCount(["library.view", "playlist.view", "dataset.view", "menuBoard.view"]) %}
{% if countViewable > 0 %}
<div class="icon-dash-section">
<h3 class="section-title"><i class="fa fa-picture-o"></i> {% trans "Library" %}</h3>
<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 & 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 playlists" %}</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 "Tabular 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 management" %}</span>
</div>
</a>
{% endif %}
</div>
</div>
{% endif %}
{# ── Displays ──────────────────────────────────────────────── #}
{% set countViewable = currentUser.featureEnabledCount(["displays.view", "displaygroup.view", "displayprofile.view"]) %}
{% if countViewable > 0 %}
<div class="icon-dash-section">
<h3 class="section-title"><i class="fa fa-desktop"></i> {% trans "Displays" %}</h3>
<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 "Manage all 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 "Organise screen groups" %}</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 "Player configuration profiles" %}</span>
</div>
</a>
{% endif %}
</div>
</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">
<h3 class="section-title"><i class="fa fa-cogs"></i> {% trans "Administration" %}</h3>
<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 "User accounts & permissions" %}</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 "CMS system configuration" %}</span>
</div>
</a>
{% endif %}
</div>
</div>
{% endif %}
<style>
/* ===================================================================
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;
}
/* 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;
}
/* Subtle radial glow matching kpi-card--modern */
.icon-dash-card::after {
content: '';
position: absolute;
inset: 0;
background: radial-gradient(circle at top right, rgba(59, 130, 246, 0.10), transparent 60%);
pointer-events: none;
transition: opacity 0.3s ease;
opacity: 0;
}
.icon-dash-card:hover::after {
opacity: 1;
}
/* Icon container */
.icon-dash-card-icon {
flex-shrink: 0;
width: 52px;
height: 52px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 14px;
font-size: 22px;
transition: transform 0.25s ease, box-shadow 0.25s ease;
}
.icon-dash-card:hover .icon-dash-card-icon {
transform: scale(1.08);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.25);
}
/* Icon colour variants */
.icon-dash-card-icon--blue {
background: linear-gradient(135deg, rgba(59, 130, 246, 0.28), rgba(59, 130, 246, 0.12));
color: #60a5fa;
}
.icon-dash-card-icon--green {
background: linear-gradient(135deg, rgba(16, 185, 129, 0.28), rgba(16, 185, 129, 0.12));
color: #34d399;
}
.icon-dash-card-icon--orange {
background: linear-gradient(135deg, rgba(245, 158, 11, 0.28), rgba(245, 158, 11, 0.12));
color: #fbbf24;
}
.icon-dash-card-icon--red {
background: linear-gradient(135deg, rgba(239, 68, 68, 0.28), rgba(239, 68, 68, 0.12));
color: #f87171;
}
.icon-dash-card-icon--purple {
background: linear-gradient(135deg, rgba(124, 58, 237, 0.28), rgba(124, 58, 237, 0.12));
color: #a78bfa;
}
.icon-dash-card-icon--indigo {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.28), rgba(99, 102, 241, 0.12));
color: #818cf8;
}
.icon-dash-card-icon--teal {
background: linear-gradient(135deg, rgba(20, 184, 166, 0.28), rgba(20, 184, 166, 0.12));
color: #2dd4bf;
}
/* Text area */
.icon-dash-card-body {
display: flex;
flex-direction: column;
gap: 2px;
min-width: 0;
/* Reset inherited dashboard-card body padding */
padding: 0 !important;
background: transparent !important;
}
.icon-dash-card-title {
font-size: 15px;
font-weight: 700;
color: var(--color-text-primary);
line-height: 1.3;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.icon-dash-card-desc {
font-size: 12px;
font-weight: 400;
color: var(--color-text-tertiary);
line-height: 1.4;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
/* Hover effects matching action-card--modern */
.icon-dash-card:hover {
border-color: rgba(59, 130, 246, 0.45) !important;
transform: translateY(-3px);
box-shadow: 0 20px 40px rgba(8, 15, 30, 0.45) !important;
}
.icon-dash-card:active {
transform: translateY(0px);
box-shadow: 0 10px 20px rgba(8, 15, 30, 0.35) !important;
}
/* Section title with icon */
.icon-dash-section .section-title i {
margin-right: 8px;
opacity: 0.65;
}
/* ── Light mode overrides ─────────────────────────────────────── */
body.ots-light-mode .icon-dash-card {
background: linear-gradient(180deg, #ffffff, #f8fafc) !important;
border-color: rgba(148, 163, 184, 0.25) !important;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.06) !important;
}
body.ots-light-mode .icon-dash-card:hover {
background: linear-gradient(180deg, #ffffff, #f1f5f9) !important;
border-color: rgba(59, 130, 246, 0.4) !important;
box-shadow: 0 12px 28px rgba(0, 0, 0, 0.1) !important;
}
body.ots-light-mode .icon-dash-card-desc {
color: #64748b;
}
/* ── 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: 12px;
}
.icon-dash-card-title {
font-size: 13px;
}
.icon-dash-card-desc {
display: none;
}
}
@media (max-width: 480px) {
.icon-dash-grid {
grid-template-columns: 1fr;
}
.icon-dash-card-desc {
display: block;
}
}
</style>
{% endblock %}

File diff suppressed because it is too large Load Diff

596
views/dataset-page.twig Normal file
View File

@@ -0,0 +1,596 @@
{#
/**
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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

@@ -0,0 +1,138 @@
/* 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;
}

261
views/daypart-page.twig Normal file
View File

@@ -0,0 +1,261 @@
{#
/**
* 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 %}

499
views/display-page.twig Normal file
View File

@@ -0,0 +1,499 @@
{#
/**
* 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

@@ -0,0 +1,377 @@
{#
/**
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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

@@ -0,0 +1,167 @@
{#
/**
* 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 %}

159
views/fonts-page.twig Normal file
View File

@@ -0,0 +1,159 @@
{#
/*
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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 %}

View File

@@ -0,0 +1,800 @@
{#
/**
* 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,
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,
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>

0
views/index.html Normal file
View File

327
views/inline.twig Normal file
View File

@@ -0,0 +1,327 @@
{% 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 %}

528
views/layout-page.twig Normal file
View File

@@ -0,0 +1,528 @@
{% 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-info 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 %}

580
views/library-page.twig Normal file
View File

@@ -0,0 +1,580 @@
{#
/**
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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 %}

120
views/login.twig Normal file
View File

@@ -0,0 +1,120 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>{{ 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="token" content="{{ csrfToken }}"/>
<meta name="public-path" content="{{ theme.rootUri() }}"/>
<link rel="shortcut icon" href="{{ theme.uri("img/favicon.ico") }}" />
<!-- 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; }
</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">
</head>
<body class="login-page">
<!-- Fallback animated background element (inline styles ensure it appears even if external CSS is cached) -->
<div class="ots-login-bg" aria-hidden="true"></div>
<!-- Animated blurred color blobs -->
<div class="ots-login-blob ots-login-blob--1" aria-hidden="true"></div>
<div class="ots-login-blob ots-login-blob--2" aria-hidden="true"></div>
<div class="ots-login-blob ots-login-blob--3" aria-hidden="true"></div>
<style>
.ots-login-bg{position:fixed;inset:0;z-index:0;pointer-events:none;filter:blur(20px);opacity:0.95;
background: linear-gradient(120deg, rgba(14,28,45,0.6), rgba(6,16,30,0.55)),
radial-gradient(circle at 10% 20%, rgba(79,140,255,0.06), transparent 10%),
radial-gradient(circle at 85% 80%, rgba(255,138,0,0.04), transparent 12%);
background-size:200% 200%,100% 100%,100% 100%;
animation: ots-login-bg-shift-inline 14s linear infinite;}
@keyframes ots-login-bg-shift-inline{0%{background-position:0% 50%,0 0,0 0}50%{background-position:100% 50%,0 0,0 0}100%{background-position:0% 50%,0 0,0 0}}
/* Ensure login card sits above fallback background */
.login-card{position:relative;z-index:2}
</style>
<div class="container">
{% if authCASEnabled %}
<form id="cas-login-form" class="login-card 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>{% trans %}Connect with the Central Authentication Server{% endtrans %}</p>
{% for loginMessage in flash('cas_login_message') %}
<div class="alert alert-danger">{{ loginMessage }}</div>
{% endfor %}
<p><button class="btn btn-signin" 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">
{% 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 class="lead">{% trans "Please provide your credentials" %}</p>
<input id="username" class="form-control input-block-level" name="username" type="text" placeholder="{% trans "User" %}" autofocus autocomplete="username">
<input id="password" class="form-control input-block-level" name="password" type="password" placeholder="{% trans "Password" %}" autocomplete="current-password">
{% for loginMessage in flash('login_message') %}
<div class="alert alert-danger">{{ loginMessage }}</div>
{% endfor %}
<p><button class="btn btn-signin" type="submit">{% trans "Login" %}</button></p>
{% if passwordReminderEnabled %}<p><a href="#" id="reminder-form-toggle">{% trans "Forgotten your password?" %}</a></p>{% endif %}
</form>
{% endif %}
{% if passwordReminderEnabled %}
<form id="reminder-form" class="login-card 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>{% trans "Please provide your user name" %}</p>
<input id="username" class="form-control input-block-level" name="username" type="text" placeholder="{% trans "User" %}">
{% for loginMessage in flash('login_message') %}
<div class="alert alert-danger">{{ loginMessage }}</div>
{% endfor %}
<p><button class="btn btn-signin" type="submit">{% trans "Send Reset" %}</button></p>
<p><a href="#" id="login-form-toggle">{% trans "Login instead?" %}</a></p>
</form>
{% 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", function (e) {
e.preventDefault();
$("#login-form").addClass("d-none");
$("#reminder-form").removeClass("d-none");
});
$("#login-form-toggle").on("click", function (e) {
e.preventDefault();
$("#login-form").removeClass("d-none");
$("#reminder-form").addClass("d-none");
});
});
</script>
</body>
</html>

200
views/menuboard-page.twig Normal file
View File

@@ -0,0 +1,200 @@
{#
/**
* 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 %}

121
views/module-page.twig Normal file
View File

@@ -0,0 +1,121 @@
{#
/*
* 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 %}

7131
views/override-styles.twig Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,20 @@
{#
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 %}
#}
<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 }}
</div>
{% endif %}
<div class="dashboard-card-body">
{% block body %}{% endblock %}
</div>
</div>

View File

@@ -0,0 +1,197 @@
{#
/**
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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 %}

551
views/playlist-page.twig Normal file
View File

@@ -0,0 +1,551 @@
{#
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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 %}

132
views/resolution-page.twig Normal file
View File

@@ -0,0 +1,132 @@
{#
/**
* 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 %}

369
views/schedule-page.twig Normal file
View File

@@ -0,0 +1,369 @@
{#
/**
* 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">
<div class="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="Calendar"></div>
</div>
</div>
<div class="row">
<div class="col-sm-12">
<div class="cal-legend">
<ul>
<li class="event-always"><span
class="fa fa-retweet"></span> {% trans "Always showing" %}</li>
<li class="event-info"><span
class="fa fa-desktop"></span> {% trans "Single Display" %}</li>
<li class="event-success"><span
class="fa fa-desktop"></span> {% trans "Multi Display" %}</li>
<li class="event-important"><span
class="fa fa-bullseye"></span> {% trans "Priority" %}</li>
<li class="event-special"><span
class="fa fa-repeat"></span> {% trans "Recurring" %}</li>
<li class="event-inverse"><span
class="fa fa-lock"></span> {% trans "View Only" %}</li>
<li class="event-command"><span
class="fa fa-wrench"></span> {% trans "Command" %}</li>
<li class="event-interrupt"><span
class="fa fa-hand-paper"></span> {% trans "Interrupt" %}</li>
<li class="event-geo-location"><span
class="fa fa-map-marker"></span> {% trans "Geo Location" %}</li>
<li class="event-action"><span
class="fa fa-paper-plane "></span> {% trans "Interactive Action" %}
</li>
<li class="event-sync"><span
class="fa fa-refresh"></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>
<script nonce="{{ cspNonce }}">
$(function() {
$('#ots-cal-prev').on('click', function() {
$('button[data-calendar-nav="prev"]').trigger('click');
});
$('#ots-cal-next').on('click', function() {
$('button[data-calendar-nav="next"]').trigger('click');
});
});
</script>
{% endblock %}

1253
views/settings-page.twig Normal file

File diff suppressed because it is too large Load Diff

187
views/syncgroup-page.twig Normal file
View File

@@ -0,0 +1,187 @@
{#
/**
* 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 %}

171
views/tag-page.twig Normal file
View File

@@ -0,0 +1,171 @@
{#
/*
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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 %}

177
views/task-page.twig Normal file
View File

@@ -0,0 +1,177 @@
{#
/*
* 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 %}

290
views/template-page.twig Normal file
View File

@@ -0,0 +1,290 @@
{#
/**
* 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

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

View File

@@ -0,0 +1,22 @@
{#
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>

1554
views/theme-scripts.twig Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,98 @@
{#
/*
* 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 %}

444
views/user-page.twig Normal file
View File

@@ -0,0 +1,444 @@
{#
/*
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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 %}

194
views/usergroup-page.twig Normal file
View File

@@ -0,0 +1,194 @@
{#
/*
* 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-up"></i>
</button>
</div>
<div class="ots-filter-content" 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 %}