2026-04-01 20:58:23 -04:00
{ #
/**
2026-04-08 12:33:52 -04:00
* OTS Signs — Layout Designer Page
2026-04-01 20:58:23 -04:00
*
2026-04-08 12:33:52 -04:00
* Overrides the Xibo core layout-designer-page.twig to add two tailored
* editor experiences alongside the standard full-page mode.
2026-04-01 20:58:23 -04:00
*
* Usage:
2026-04-08 12:33:52 -04:00
* Normal: /layout/designer/ { layoutId}
* Deep-link: /layout/designer/ { layoutId}?deeplink=1[&returnUrl=/app/layouts/123]
* Embed: /layout/designer/ { layoutId}?embed=1
2026-04-01 20:58:23 -04:00
*
2026-04-08 12:33:52 -04:00
* Deep-link mode (?deeplink=1):
* - Designed for direct navigation from an external React app.
* - All Xibo chrome is stripped (sidebar, topbar, help pane, back buttons).
* - A minimal OTS branded back bar is rendered at the top of the viewport.
* - Clicking "Back" navigates to ?returnUrl= (relative paths only) or
* falls back to window.history.back() if the param is absent/invalid.
* - The editor options dropdown (Publish, Checkout, Discard…) stays visible.
*
* Embed mode (?embed=1):
* - All CMS navigation is hidden (sidebar, topbar, help pane).
* - Back/Exit buttons in the editor toolbar are hidden.
2026-04-01 20:58:23 -04:00
* - postMessage events are sent to the parent window:
* xibo:editor:ready, xibo:editor:save, xibo:editor:publish, xibo:editor:exit
* - Parent can send: xibo:editor:requestSave
*
* Copyright (C) 2020-2026 Xibo Signage Ltd
* Copyright (C) 2026 Oribi Technology Services
*
* Licensed under the GNU Affero General Public License v3.0
*/
#}
{% extends "authed.twig" %}
{% import "inline.twig" as inline %}
2026-04-10 20:32:49 -04:00
{% block title %} {{ layout .layout }} | {{ "Layout Designer" | trans }} | {% endblock %}
2026-04-01 20:58:23 -04:00
{% set hideNavigation = "1" %}
{% set forceHide = true %}
{% block headContent %}
{{ parent ( ) }}
2026-04-08 12:33:52 -04:00
{# Early mode detection — prevents FOUC for both embed and deep-link modes #}
2026-04-01 20:58:23 -04:00
<script nonce=" {{ cspNonce }} ">
(function() {
try {
var params = new URLSearchParams(window.location.search);
var isEmbed = params.get('embed') === '1' || window !== window.parent;
if (isEmbed) {
document.documentElement.classList.add('ots-embed-mode');
}
2026-04-08 12:33:52 -04:00
if (params.get('deeplink') === '1') {
document.documentElement.classList.add('ots-deeplink-mode');
}
2026-04-01 20:58:23 -04:00
} catch(e) {
// Cross-origin iframe detection may throw — treat as embed
document.documentElement.classList.add('ots-embed-mode');
}
})();
</script>
<style nonce=" {{ cspNonce }} ">
2026-04-11 14:05:17 -04:00
/* ── Ensure editor pop-ups appear above our fixed action bars ───────────── */
/* Our #ots-editor-bar sits at z-index 1300. Bootstrap modals default to
backdrop:1040 / modal:1050, so they render behind it. Raise them to
1302/1303 so the Checkout / welcome dialogs always appear on top. */
.modal-backdrop { z-index: 1302 !important; }
.modal { z-index: 1303 !important; }
2026-04-01 20:58:23 -04:00
/* ── Embed mode styles ──────────────────────────────────── */
/* Hide Back/Exit button area */
.ots-embed-mode .back-button {
display: none !important;
}
/* Hide the help pane */
.ots-embed-mode #help-pane {
display: none !important;
}
/* Remove content wrapper padding/margins for full-bleed editor */
.ots-embed-mode #content-wrapper {
padding: 0 !important;
margin: 0 !important;
}
.ots-embed-mode .page-content {
padding: 0 !important;
margin: 0 !important;
}
.ots-embed-mode .page-content > .row {
margin: 0 !important;
}
.ots-embed-mode .page-content > .row > .col-sm-12 {
padding: 0 !important;
}
/* Full viewport height for the editor */
.ots-embed-mode body,
.ots-embed-mode #layout-editor {
min-height: 100vh;
}
/* Hide the top-bar options dropdown (Publish/Checkout/Discard/etc.) */
.ots-embed-mode .editor-top-bar .editor-options-dropdown {
display: none !important;
}
/* Hide the editor jump list (layout switcher) */
.ots-embed-mode .editor-top-bar #layoutJumpListContainer {
display: none !important;
}
2026-04-08 12:33:52 -04:00
/* ── Deep-link mode styles ──────────────────────────────── */
/* Hide Back/Exit button area */
.ots-deeplink-mode .back-button {
display: none !important;
}
/* Hide the help pane */
.ots-deeplink-mode #help-pane {
display: none !important;
}
/* Remove content wrapper padding/margins for full-bleed editor */
.ots-deeplink-mode #content-wrapper {
padding: 0 !important;
margin: 0 !important;
}
.ots-deeplink-mode .page-content {
padding: 0 !important;
margin: 0 !important;
}
.ots-deeplink-mode .page-content > .row {
margin: 0 !important;
}
.ots-deeplink-mode .page-content > .row > .col-sm-12 {
padding: 0 !important;
}
/* Full viewport height for the editor */
.ots-deeplink-mode body,
.ots-deeplink-mode #layout-editor {
min-height: 100vh;
}
2026-04-11 14:05:17 -04:00
/* ── Editor positioning fix ─────────────────────────────────────────────
Xibo's editor internal structure (from layout-editor.hbs + layout-editor.scss):
#layout-editor
.editor-top-bar ← always min-height ~40px in normal flow;
its children are position:fixed top:0 (covered by our bar)
.container-designer { height: calc(100vh - 50px) }
Keeping #layout-editor in normal flow and hiding .editor-top-bar removes
the 40px gap. We then push .container-designer down with margin-top and
resize it so it fills exactly from below our bar to the viewport bottom.
─────────────────────────────────────────────────────────────────────── */
/* Restore #layout-editor to normal document flow */
#layout-editor {
position: static !important;
top: auto !important;
left: auto !important;
right: auto !important;
bottom: auto !important;
width: auto !important;
height: auto !important;
/* Preserve Xibo's horizontal negative margins (Bootstrap column gutter) */
margin: 0 -15px -15px -15px !important;
2026-04-08 12:33:52 -04:00
}
2026-04-11 14:05:17 -04:00
/* Xibo's .editor-top-bar inner items use position:fixed top:0 so they
already render behind our #ots-editor-bar (z-index 1300). Hiding the
container removes the dead 40px normal-flow space it leaves behind. */
.editor-top-bar {
display: none !important;
}
2026-04-08 12:33:52 -04:00
2026-04-11 14:05:17 -04:00
/* Reposition the playlist-editor "Back to Layout" button below our bar
instead of hiding it — it's shown by lD.openPlaylistEditor() */
.back-button-playlist {
top: 50px !important;
z-index: 1290 !important;
}
/* Push the canvas below our 44px bar and fill the remaining viewport height */
body.editor-opened .container-designer {
margin-top: 44px !important;
height: calc(100vh - 44px) !important;
}
/* Loading spinner while editor data loads — same offset */
body.editor-opened #layout-editor .loading-container {
margin-top: 44px !important;
height: calc(100vh - 44px) !important;
}
/* Embed mode: no bar — canvas fills the full viewport */
.ots-embed-mode .container-designer,
.ots-embed-mode #layout-editor .loading-container {
margin-top: 0 !important;
height: 100vh !important;
}
/* Remove body-level padding (harmless safety reset) */
body {
padding-top: 0 !important;
}
/* Ensure no stray padding above the editor from page-wrapper */
#page-wrapper,
#content-wrapper {
padding-top: 0 !important;
}
/* ── OTS combined editor bar ──────────────────────────── */
/* Single fixed bar: back button + layout name + action buttons */
#ots-editor-bar {
display: flex;
2026-04-08 12:33:52 -04:00
position: fixed;
top: 0;
left: 0;
right: 0;
height: 44px;
background: #0f172a;
border-bottom: 2px solid #e87800;
z-index: 1300;
align-items: center;
padding: 0 12px;
2026-04-11 14:05:17 -04:00
gap: 8px;
2026-04-08 12:33:52 -04:00
box-sizing: border-box;
}
2026-04-11 14:05:17 -04:00
/* Hide bar inside an iframe embed */
.ots-embed-mode #ots-editor-bar {
display: none;
2026-04-08 12:33:52 -04:00
}
2026-04-11 14:05:17 -04:00
#ots-bar-back {
2026-04-08 12:33:52 -04:00
display: inline-flex;
align-items: center;
gap: 6px;
background: none;
border: none;
color: #e87800;
font-size: 14px;
font-weight: 500;
cursor: pointer;
padding: 6px 10px;
border-radius: 4px;
line-height: 1;
transition: background 0.15s;
2026-04-11 14:05:17 -04:00
flex-shrink: 0;
2026-04-08 12:33:52 -04:00
}
2026-04-11 14:05:17 -04:00
#ots-bar-back:hover {
2026-04-08 12:33:52 -04:00
background: rgba(232, 120, 0, 0.12);
}
2026-04-11 14:05:17 -04:00
#ots-bar-back:focus-visible {
2026-04-08 12:33:52 -04:00
outline: 2px solid #e87800;
outline-offset: 2px;
}
2026-04-11 14:05:17 -04:00
#ots-bar-back svg {
2026-04-08 12:33:52 -04:00
flex-shrink: 0;
}
2026-04-11 14:05:17 -04:00
/* Vertical divider between back button and layout name */
#ots-bar-divider {
width: 1px;
height: 20px;
background: #334155;
flex-shrink: 0;
}
/* ── Hide stock Xibo header bar on the layout designer page ── */
.row.header.header-side {
display: none !important;
}
/* ── Remove sidebar offset so editor fills the full viewport ── */
#page-wrapper,
#content-wrapper {
margin-left: 0 !important;
padding-left: 0 !important;
width: 100% !important;
max-width: 100% !important;
}
.page-content,
.page-content > .row,
.page-content > .row > .col-sm-12 {
padding: 0 !important;
margin: 0 !important;
}
/* Layout name / status label */
#ots-bar-label {
flex: 1 1 auto;
min-width: 0;
font-size: 13px;
color: #94a3b8;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
padding-left: 4px;
}
.ots-bar-status {
display: inline-block;
font-size: 10px;
font-weight: 600;
letter-spacing: 0.05em;
text-transform: uppercase;
padding: 2px 6px;
border-radius: 3px;
margin-left: 8px;
vertical-align: middle;
background: rgba(232, 120, 0, 0.15);
color: #e87800;
}
.ots-bar-status.ots-status-published {
background: rgba(74, 222, 128, 0.15);
color: #4ade80;
}
/* Shared button base */
.ots-editor-bar-btn {
display: inline-flex;
align-items: center;
gap: 5px;
height: 30px;
padding: 0 12px;
border-radius: 4px;
font-size: 13px;
2026-04-08 12:33:52 -04:00
font-weight: 500;
2026-04-11 14:05:17 -04:00
cursor: pointer;
border: 1px solid transparent;
line-height: 1;
background: none;
font-family: inherit;
transition: background 0.15s, opacity 0.15s, border-color 0.15s;
white-space: nowrap;
flex-shrink: 0;
}
/* Save — filled orange */
#ots-editor-save-btn {
background: #e87800;
color: #fff;
border-color: #e87800;
}
#ots-editor-save-btn:hover {
background: #d16b00;
border-color: #d16b00;
}
/* Brief green flash after a successful save AJAX round-trip */
#ots-editor-save-btn.ots-btn-saved {
background: #16a34a !important;
border-color: #16a34a !important;
transition: none;
}
/* Publish — orange outline */
#ots-editor-publish-btn {
color: #e87800;
border-color: #e87800;
}
#ots-editor-publish-btn:hover {
background: rgba(232, 120, 0, 0.1);
}
/* Checkout — muted outline */
#ots-editor-checkout-btn {
color: #94a3b8;
border-color: #334155;
}
#ots-editor-checkout-btn:hover {
background: rgba(148, 163, 184, 0.1);
border-color: #64748b;
color: #cbd5e1;
}
/* Draft state: show Save + Publish; hide Checkout */
#ots-editor-bar .ots-draft-only { display: inline-flex; }
#ots-editor-bar .ots-readonly-only { display: none; }
/* Read-only/published state: hide Save + Publish; show Checkout */
#ots-editor-bar.ots-state-readonly .ots-draft-only { display: none !important; }
#ots-editor-bar.ots-state-readonly .ots-readonly-only { display: inline-flex !important; }
.ots-editor-bar-btn:disabled,
.ots-editor-bar-btn.ots-btn-loading {
opacity: 0.45;
cursor: default;
2026-04-08 12:33:52 -04:00
pointer-events: none;
2026-04-11 14:05:17 -04:00
}
.ots-editor-bar-btn:focus-visible {
outline: 2px solid #e87800;
outline-offset: 2px;
}
/* ── Interactive mode toggle ──────────────────────── */
#ots-editor-interactive-btn {
color: #94a3b8;
border-color: #334155;
}
#ots-editor-interactive-btn:hover {
background: rgba(148, 163, 184, 0.1);
border-color: #64748b;
color: #cbd5e1;
}
/* Lit up when interactive mode is active */
#ots-editor-interactive-btn.ots-btn-active {
background: rgba(232, 120, 0, 0.15);
border-color: #e87800;
color: #e87800;
}
/* Not useful in embed mode */
.ots-embed-mode #ots-editor-interactive-btn { display: none !important; }
/* Secondary divider between interactive toggle and primary action buttons */
#ots-bar-actions-divider {
width: 1px;
height: 20px;
background: #334155;
flex-shrink: 0;
}
/* ── Discard — destructive/red outline, draft-only ── */
#ots-editor-discard-btn {
color: #f87171;
border-color: #7f1d1d;
}
#ots-editor-discard-btn:hover {
background: rgba(248, 113, 113, 0.1);
border-color: #f87171;
}
/* ── Schedule — muted outline, readonly-only ─────── */
#ots-editor-schedule-btn {
color: #94a3b8;
border-color: #334155;
}
#ots-editor-schedule-btn:hover {
background: rgba(148, 163, 184, 0.1);
border-color: #64748b;
color: #cbd5e1;
}
/* ── Unlock — amber, hidden until layout is locked ── */
#ots-editor-unlock-btn {
display: none;
color: #fbbf24;
border-color: #78350f;
}
#ots-editor-unlock-btn:hover {
background: rgba(251, 191, 36, 0.1);
border-color: #fbbf24;
}
/* .ots-bar-locked is added via JS when lD detects locked-for-user */
#ots-editor-bar.ots-bar-locked #ots-editor-unlock-btn {
display: inline-flex;
2026-04-08 12:33:52 -04:00
}
2026-04-01 20:58:23 -04:00
</style>
{% endblock %}
{% block pageContent %}
2026-04-11 14:05:17 -04:00
{# OTS combined editor bar — back button + layout name + action buttons #}
<div id="ots-editor-bar" role="banner" aria-label=" {{ "Layout editor" | trans }} ">
<button id="ots-bar-back" type="button" aria-label=" {{ "Back" | trans }} ">
2026-04-08 12:33:52 -04:00
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M10 12L6 8l4-4" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Back" | trans }}
</button>
2026-04-11 14:05:17 -04:00
<div id="ots-bar-divider" aria-hidden="true"></div>
<span id="ots-bar-label">
{{ layout .layout }}
<span class="ots-bar-status" id="ots-bar-status-badge"> {{ "Draft" | trans }} </span>
</span>
{# Interactive mode toggle — available in both draft and read-only #}
<button id="ots-editor-interactive-btn" type="button" class="ots-editor-bar-btn"
title=" {{ "Toggle interactive mode to link regions with actions" | trans }} ">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M4 2l2 9 2.5-3H12L4 2z" stroke="currentColor" stroke-width="1.5" stroke-linejoin="round"/>
</svg>
{{ "Interactive" | trans }}
</button>
<div id="ots-bar-actions-divider" aria-hidden="true"></div>
{# Draft state: Save + Publish + Discard #}
<button id="ots-editor-save-btn" type="button" class="ots-editor-bar-btn ots-draft-only"
title=" {{ "Save current changes" | trans }} ">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M8 2v7m0 0L5 6m3 3l3-3M3 13h10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Save" | trans }}
</button>
<button id="ots-editor-publish-btn" type="button" class="ots-editor-bar-btn ots-draft-only"
title=" {{ "Publish layout to displays" | trans }} ">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M8 11V4m0 0L5 7m3-3l3 3M3 13h10" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Publish" | trans }}
</button>
<button id="ots-editor-discard-btn" type="button" class="ots-editor-bar-btn ots-draft-only"
title=" {{ "Discard this draft and revert to the published version" | trans }} ">
<svg width="12" height="12" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M4 4l8 8M12 4l-8 8" stroke="currentColor" stroke-width="1.75" stroke-linecap="round"/>
</svg>
{{ "Discard" | trans }}
</button>
{# Read-only/published state: Checkout + Schedule #}
<button id="ots-editor-checkout-btn" type="button" class="ots-editor-bar-btn ots-readonly-only"
title=" {{ "Checkout this layout for editing" | trans }} ">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<path d="M10.5 2.5l3 3L5 14H2v-3L10.5 2.5z" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M9 4l3 3" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{{ "Checkout" | trans }}
</button>
<button id="ots-editor-schedule-btn" type="button" class="ots-editor-bar-btn ots-readonly-only"
title=" {{ "Schedule this layout on displays" | trans }} ">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<circle cx="8" cy="8" r="6" stroke="currentColor" stroke-width="1.5"/>
<path d="M8 5v3.5l2.5 2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
{{ "Schedule" | trans }}
</button>
{# Unlock: hidden by default, revealed via JS when lD marks the layout as locked-for-user #}
<button id="ots-editor-unlock-btn" type="button" class="ots-editor-bar-btn"
title=" {{ "Unlock this layout" | trans }} ">
<svg width="13" height="13" viewBox="0 0 16 16" fill="none" aria-hidden="true" focusable="false">
<rect x="3" y="8" width="10" height="6" rx="1.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M6 8V5a3 3 0 016 0v2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>
{{ "Unlock" | trans }}
</button>
</div> {# /ots-editor-bar #}
2026-04-08 12:33:52 -04:00
2026-04-01 20:58:23 -04:00
<!-- Editor structure -->
<div id="layout-editor" data-published-layout-id=" {{ publishedLayoutId }} " data-layout-id=" {{ layout .layoutId }} " data-layout-help= {{ help }} ></div>
{% endblock %}
{% block javaScript %}
{# Add common files #}
{% include "editorTranslations.twig" %}
{% include "editorVars.twig" %}
<script src=" {{ theme .rootUri ( ) }} dist/layoutEditor.bundle.min.js?v= {{ version }} &rev= {{ revision }} " nonce=" {{ cspNonce }} "></script>
<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 }} ">
var previewJwt = " {{ previewJwt }} ";
{% autoescape "js" %}
{# Custom translations #}
var layoutEditorHelpLink = " {{ help }} ";
var layoutEditorTrans = {
back: " {% trans "Back" %} ",
exit: " {% trans "Exit" %} ",
cancel: " {% trans "Cancel" %} ",
toggleFullscreen: " {% trans "Toggle Fullscreen Mode" %} ",
layerManager: " {% trans "Layer Manager" %} ",
snapToGrid: " {% trans "Snap to Grid" %} ",
snapToBorders: " {% trans "Snap to Borders" %} ",
snapToElements: " {% trans "Snap to Elements" %} ",
newTitle: " {% trans "New" %} ",
publishTitle: " {% trans "Publish" %} ",
discardTitle: " {% trans "Discard draft" %} ",
deleteTitle: " {% trans "Delete" %} ",
publishMessage: " {% trans "Are you sure you want to publish this Layout? If it is already in use the update will automatically get pushed." %} ",
checkoutTitle: " {% trans "Checkout" %} ",
scheduleTitle: " {% trans "Schedule" %} ",
clearLayout: " {% trans "Clear Canvas" %} ",
unlockTitle: " {% trans "Unlock" %} ",
saveTemplateTitle: " {% trans "Save Template" %} ",
readOnlyModeTitle: " {% trans "Read Only" %} ",
readOnlyModeMessage: " {% trans "You are viewing this Layout in read only mode, checkout by clicking on this message or from the Options menu above!" %} ",
lockedModeTitle: " {% trans "Locked" %} ",
lockedModeMessage: " {% trans "This is being locked by another user. Lock expires on: [expiryDate]" %} ",
checkoutMessage: " {% trans "Not editable, please checkout!" %} ",
unlockMessage: " {% trans "The current layout will be unlocked to other users. You will also be redirected to the Layouts page" %} ",
viewModeTitle: " {% trans "View" %} ",
actions: " {% trans "Actions" %} ",
welcomeModalMessage: " {% trans "This is published and cannot be edited. You can checkout for editing below, or continue to view it in a read only mode." %} ",
showingSampleData: " {% trans "Showing sample data" %} ",
emptyElementData: " {% trans "Has empty data" %} "
};
var viewerTrans = {
inlineEditor: " {% trans "Inline Editor" %} ",
nextWidget: " {% trans "Next widget" %} ",
previousWidget: " {% trans "Previous widget" %} ",
addWidget: " {% trans "Add Widget" %} ",
editGroup: " {% trans "Edit Group" %} ",
editPlaylist: " {% trans "Edit Playlist" %} ",
prev: ' {{ "Previous Widget" | trans }} ',
next: ' {{ "Next Widget" | trans }} ',
empty: ' {{ "Empty Playlist" | trans }} ',
invalidRegion: ' {{ "Invalid Region" | trans }} ',
editPlaylistTitle: ' {{ "Edit Playlist" | trans }} ',
dynamicPlaylistTitle: ' {{ "Dynamic Playlist" | trans }} '
};
var timelineTrans = {
zoomIn: " {% trans "Zoom in" %} ",
zoomOut: " {% trans "Zoom out" %} ",
resetZoom: " {% trans "Reset zoom" %} ",
zoomDelta: " {% trans "Visible area time span" %} ",
hiddenTimeruler: " {% trans "Zoom out to see timeruler!" %} ",
emptyTimeline: " {% trans "No Regions: Add a Region to start creating content by clicking here or the Edit Layout icon below!" %} ",
zoomFindSelected: " {% trans "Scroll to selected widget" %} ",
startTime: " {% trans "Visible area start time" %} ",
endTime: " {% trans "Visible area end time" %} ",
layoutName: " {% trans "Layout name" %} ",
layoutDuration: " {% trans "Layout duration" %} ",
layoutDimensions: " {% trans "Layout dimensions" %} ",
addToThisPosition: " {% trans "Add to this position" %} ",
hiddenContentInWidget: " {% trans "Zoom in to see more details!" %} ",
editRegion: " {% trans "Edit region" %} ",
openRegionAsPlaylist: " {% trans "Open as playlist" %} ",
widgetActions: " {% trans "Widget Actions:" %} ",
regionActions: " {% trans "Region Actions:" %} "
};
var bottombarTrans = {
edit: " {% trans "Edit layout regions" %} ",
addRegion: " {% trans "Add" %} ",
addRegionDesc: " {% trans "Add a new region" %} ",
deleteRegion: " {% trans "Delete region" %} ",
undo: " {% trans "Undo" %} ",
undoDesc: " {% trans "Revert last change" %} ",
close: " {% trans "Close" %} ",
closeDesc: " {% trans "Return to Layout View" %} ",
save: " {% trans "Save" %} ",
saveDesc: " {% trans "Save changes" %} ",
backToLayout: " {% trans "Go back to Layout view" %} ",
saveEditorChanges: " {% trans "Save editor changes" %} ",
playPreviewLayout: " {% trans "Play Layout preview" %} ",
playPreviewLayoutPOTitle: " {% trans "Preview stopped!" %} ",
playPreviewLayoutPOMessage: " {% trans "Click to Play again" %} ",
editLayout: " {% trans "Edit Layout" %} ",
stopPreviewLayout: " {% trans "Stop Layout preview" %} ",
nextWidget: " {% trans "Next widget" %} ",
previousWidget: " {% trans "Previous widget" %} ",
widgetName: " {% trans "Widget Name" %} ",
widgetType: " {% trans "Widget Type" %} ",
widgetTemplate: " {% trans "Widget Template Name" %} ",
elementName: " {% trans "Element Name" %} ",
elementMediaInfoName: " {{ "Media Name" | trans }} ",
elementMediaInfoId: " {{ "Media ID" | trans }} ",
elementGroupName: " {% trans "Element Group Name" %} ",
regionName: " {% trans "Region Name" %} ",
templateName: " {% trans "Template" %} ",
objectType: {
layout: " {{ "Layout" | trans }} ",
region: " {{ "Region" | trans }} ",
zone: " {{ "Zone" | trans }} ",
playlist: " {{ "Playlist" | trans }} ",
widget: " {{ "Widget" | trans }} ",
element: " {{ "Element" | trans }} ",
"element-group": " {{ "Element Group" | trans }} "
},
tools: {
audio: {
name: " {{ "Audio" | trans }} ",
description: " {{ "Upload Audio files to assign to Widgets" | trans }} "
},
transitionIn: {
name: " {{ "Transition In" | trans }} ",
description: " {{ "Apply a Transition type for the start of a media item" | trans }} "
},
transitionOut: {
name: " {{ "Transition Out" | trans }} ",
description: " {{ "Apply a Transition type for the end of a media item" | trans }} "
},
permissions: {
name: " {{ "Sharing" | trans }} ",
description: " {{ "Set View, Edit and Delete Sharing for Widgets and Playlists" | trans }} "
}
}
};
{% endautoescape %}
</script>
<script type="text/javascript" nonce=" {{ cspNonce }} ">
/**
* Setup the background form.
*/
function backGroundFormSetup(dialog) {
var $backgroundImageId = $('[name="backgroundImageId"]', dialog);
var notFoundIcon = $('#bg_not_found_icon', dialog);
var bgImageFileName = $('#bg_media_name', dialog);
var saveButton = $('button#save', dialog);
var initialBackgroundImageId = $backgroundImageId.val();
var backgroundChanged = false;
var mediaName = '';
function backgroundImageChange() {
var id = $backgroundImageId.val();
var isNotDefined = [0, ''].indexOf(id) !== -1;
$('#backgroundRemoveButton').toggleClass('disabled', isNotDefined);
if (isNotDefined) {
notFoundIcon.show();
bgImageFileName.hide();
} else {
notFoundIcon.hide();
bgImageFileName.show();
if(mediaName) {
bgImageFileName.html(mediaName);
}
if (id !== initialBackgroundImageId) {
saveButton.trigger('click');
}
}
if (id !== initialBackgroundImageId) {
backgroundChanged = true;
}
}
function backgroundImageHandleDrop(mediaToAdd, fromProvider) {
if(fromProvider) {
lD.importFromProvider([mediaToAdd]).then((res) => {
$backgroundImageId.val(res[0]).trigger('change');
}).catch(function() {
toastr.error(errorMessagesTrans.importingMediaFailed);
});
} else {
$backgroundImageId.val(mediaToAdd).trigger('change');
}
lD.toolbar.deselectCardsAndDropZones();
}
$backgroundImageId.change(backgroundImageChange);
backgroundImageChange();
$('#backgroundUploadButton').on('click', function(e) {
layoutEditBackgroundButtonClicked(e, dialog);
});
$('#backgroundRemoveButton').on('click', function(e) {
if(!$(this).hasClass('disabled')) {
$backgroundImageId.val('').trigger('change');
}
});
$('.background-image-add').droppable( {
greedy: true,
tolerance: 'pointer',
accept: function(el) {
return ($(el).data('type') === 'media' && $(el).data('subType') === 'image');
},
drop: _.debounce(function(event, ui) {
var $draggable = $(ui.draggable[0]);
bgImageFileName.html($draggable.data('title'));
mediaName = $draggable.data('cardTitle');
if($draggable.hasClass('from-provider')) {
backgroundImageHandleDrop($draggable.data('providerData'), true);
} else {
backgroundImageHandleDrop($draggable.data('mediaId'));
}
}, 200)
});
$('.background-image-drop').on('click', function() {
var selectedCard = lD.toolbar.selectedCard;
var fromProvider = selectedCard.hasClass('from-provider');
var cardData = (fromProvider) ? selectedCard.data('providerData') : selectedCard.data('mediaId');
bgImageFileName.html(selectedCard.data('cardTitle'));
mediaName = selectedCard.data('cardTitle');
backgroundImageHandleDrop(cardData, fromProvider);
});
$("#layoutEditForm").submit(function(e) {
e.preventDefault();
var form = $(this);
$.ajax( {
type: form.attr("method"),
url: form.attr("action"),
cache: false,
dataType: "json",
data: $(form).serialize(),
success: function(xhr, textStatus, error) {
XiboSubmitResponse(xhr, form);
if (xhr.success) {
var layout = $("div#layout");
if (layout.length > 0) {
var color = form.find("#backgroundColor").val();
layout.data().backgroundColor = color;
layout.css("background-color", color);
if (backgroundChanged)
window.location.reload();
} else {
if (backgroundChanged && typeof(table) !== 'undefined' && table.hasOwnProperty('ajax'))
table.ajax.reload(null, false);
}
}
},
error: function(xhr, textStatus, errorThrown) {
SystemMessage(xhr.responseText, false);
}
});
})
};
/**
* Layout edit background add image button
*/
function layoutEditBackgroundButtonClicked(e, dialog) {
e.preventDefault();
openUploadForm( {
url: $(e.target).data().addNewBackgroundUrl,
title: " {% trans "Add Background Image" %} ",
videoImageCovers: false,
buttons: {
main: {
label: " {% trans "Done" %} ",
className: "btn-primary btn-bb-main",
callback: function () {
XiboDialogClose();
}
}
},
templateOptions: {
multi: false,
trans: {
addFiles: " {% trans "Browse/Add Image" %} ",
startUpload: " {% trans "Start Upload" %} ",
cancelUpload: " {% trans "Cancel Upload" %} "
},
upload: {
maxSize: {{ libraryUpload .maxSize }} ,
maxSizeMessage: " {{ libraryUpload .maxSizeMessage }} ",
validExt: " {{ libraryUpload .validImageExt }} "
}
},
uploadDoneEvent: function (data) {
var mediaId = data.result.files[0].mediaId;
if ($(dialog).find('[name="backgroundImageId"]').length === 0) {
$('<input>').attr( {
type: 'hidden',
name: 'backgroundImageId',
value: mediaId
}).appendTo(dialog);
} else {
$('[name="backgroundImageId"]').val(mediaId);
}
dialog.find("#bg_not_found_icon").hide();
dialog.find("#backgroundRemoveButton").removeClass("disabled");
XiboDialogClose();
$('[name="backgroundImageId"]').trigger('change');
}
});
}
function layoutPublishFormOpen() {
}
function layoutEditFormSaved() {
lD.reloadData(lD.layout, {
refreshEditor: true,
});
}
</script>
{# ── Embed mode: postMessage bridge ──────────────────────── #}
<script type="text/javascript" nonce=" {{ cspNonce }} ">
(function() {
'use strict';
var params = new URLSearchParams(window.location.search);
var isEmbed = false;
try {
isEmbed = params.get('embed') === '1' || window !== window.parent;
} catch(e) {
isEmbed = true;
}
if (!isEmbed) return;
// Add embed class to body once DOM is ready
document.body.classList.add('ots-embed-mode');
var layoutId = document.getElementById('layout-editor')
? document.getElementById('layout-editor').getAttribute('data-layout-id')
: null;
2026-04-07 19:37:39 -04:00
// Restrict postMessage to the known React app origin.
// window.location.origin is the CMS origin; the parent app is on the same host.
var targetOrigin = window.location.origin;
2026-04-01 20:58:23 -04:00
/**
* Send a message to the parent window.
*/
function sendToParent(type, data) {
if (window.parent && window.parent !== window) {
var message = { type: type, layoutId: layoutId };
if (data) {
for (var key in data) {
if (data.hasOwnProperty(key)) {
message[key] = data[key];
}
}
}
window.parent.postMessage(message, targetOrigin);
}
}
// ── Intercept editor save/publish via ajaxComplete ──────
$(document).ajaxComplete(function(event, xhr, settings) {
if (!settings || !settings.url) return;
try {
var response = xhr.responseJSON || { };
if (!response.success) return;
// Detect layout save (PUT to layout endpoint)
if (settings.type === 'PUT' && settings.url.match(/\/layout\/\d+/)) {
sendToParent('xibo:editor:save', { url: settings.url });
}
// Detect layout publish
if (settings.url.match(/\/layout\/publish\/\d+/)) {
sendToParent('xibo:editor:publish', { url: settings.url });
}
// Detect checkout
if (settings.url.match(/\/layout\/checkout\/\d+/)) {
sendToParent('xibo:editor:checkout', {
url: settings.url,
newLayoutId: response.id || null
});
}
} catch(e) {
// Silently ignore parse errors
}
});
// ── Intercept Back/Exit navigation ──────────────────────
// The editor sets window.location.href to exitURL — intercept it
$(document).on('click', '.back-button a, .editor-close-btn', function(e) {
e.preventDefault();
e.stopPropagation();
sendToParent('xibo:editor:exit', { });
});
// ── Listen for commands from parent ─────────────────────
window.addEventListener('message', function(event) {
var msg = event.data;
if (!msg || typeof msg.type !== 'string') return;
switch (msg.type) {
case 'xibo:editor:requestSave':
// Trigger the properties panel save if pending
if (typeof lD !== 'undefined' && lD.propertiesPanel && lD.propertiesPanel.toSave) {
lD.propertiesPanel.save( { target: lD.selectedObject });
}
break;
case 'xibo:editor:requestPublish':
if (typeof lD !== 'undefined' && lD.showPublishScreen) {
lD.showPublishScreen();
}
break;
2026-04-06 06:24:07 -04:00
case 'xibo:editor:setTheme': {
var newMode = msg.mode;
if (newMode === 'light') {
document.documentElement.classList.add('ots-light-mode');
document.body.classList.add('ots-light-mode');
} else {
document.documentElement.classList.remove('ots-light-mode');
document.body.classList.remove('ots-light-mode');
}
try { localStorage.setItem('ots-theme-mode', newMode); } catch(ignore) { }
break;
}
2026-04-01 20:58:23 -04:00
}
});
// ── Notify parent when editor is ready ──────────────────
2026-04-06 06:24:07 -04:00
// Fire xibo:editor:error if editor hasn't opened within 15 s
var initErrTimeout = setTimeout(function() {
if (!document.body.classList.contains('editor-opened')) {
sendToParent('xibo:editor:error', { reason: 'timeout' });
}
}, 15000);
2026-04-01 20:58:23 -04:00
var readyObserver = new MutationObserver(function(mutations) {
if (document.body.classList.contains('editor-opened')) {
2026-04-06 06:24:07 -04:00
clearTimeout(initErrTimeout);
sendToParent('xibo:editor:ready', {
theme: document.documentElement.classList.contains('ots-light-mode') ? 'light' : 'dark'
});
2026-04-01 20:58:23 -04:00
readyObserver.disconnect();
}
});
if (document.body.classList.contains('editor-opened')) {
2026-04-06 06:24:07 -04:00
clearTimeout(initErrTimeout);
sendToParent('xibo:editor:ready', {
theme: document.documentElement.classList.contains('ots-light-mode') ? 'light' : 'dark'
});
2026-04-01 20:58:23 -04:00
} else {
readyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
})();
</script>
2026-04-08 12:33:52 -04:00
2026-04-11 14:05:17 -04:00
{# ── Back bar: returnUrl navigation (always active) ────────── #}
2026-04-08 12:33:52 -04:00
<script type="text/javascript" nonce=" {{ cspNonce }} ">
(function() {
'use strict';
var params = new URLSearchParams(window.location.search);
2026-04-11 14:05:17 -04:00
// Still mirror deeplink class for any other deeplink-specific behaviour.
if (params.get('deeplink') === '1') {
document.body.classList.add('ots-deeplink-mode');
}
2026-04-08 12:33:52 -04:00
/**
* Allow only safe same-origin relative paths.
* Accepts: /app/layouts, /layouts/123?foo=bar
* Rejects: //evil.com, https://evil.com, javascript:void(0), empty string
*/
function validateReturnUrl(url) {
if (!url || typeof url !== 'string') return false;
// Must start with '/' but not '//' (protocol-relative URL)
if (url.charAt(0) !== '/' || url.charAt(1) === '/') return false;
// Reject anything containing a scheme (e.g. https://)
if (url.indexOf('://') !== -1) return false;
return true;
}
var rawReturn = params.get('returnUrl');
var safeReturn = validateReturnUrl(rawReturn) ? rawReturn : null;
2026-04-11 14:05:17 -04:00
var backBtn = document.getElementById('ots-bar-back');
2026-04-08 12:33:52 -04:00
if (backBtn) {
backBtn.addEventListener('click', function() {
if (safeReturn) {
window.location.href = safeReturn;
} else {
window.history.back();
}
});
}
})();
</script>
2026-04-11 14:05:17 -04:00
{# ── OTS Editor Action Bar: state management & button wiring ── #}
<script type="text/javascript" nonce=" {{ cspNonce }} ">
(function() {
'use strict';
var bar = document.getElementById('ots-editor-bar');
var saveBtn = document.getElementById('ots-editor-save-btn');
var publishBtn = document.getElementById('ots-editor-publish-btn');
var checkoutBtn = document.getElementById('ots-editor-checkout-btn');
var discardBtn = document.getElementById('ots-editor-discard-btn');
var interactiveBtn = document.getElementById('ots-editor-interactive-btn');
var scheduleBtn = document.getElementById('ots-editor-schedule-btn');
var unlockBtn = document.getElementById('ots-editor-unlock-btn');
var statusBadge = document.getElementById('ots-bar-status-badge');
if (!bar || !saveBtn || !publishBtn || !checkoutBtn) return;
// ── State management ──────────────────────────────────────
// 'draft' → Save + Publish visible, Checkout hidden
// 'readonly' → Checkout visible, Save + Publish hidden
function setBarState(state) {
if (state === 'readonly') {
bar.classList.add('ots-state-readonly');
if (statusBadge) {
statusBadge.textContent = ' {{ "Published" | trans }} ';
statusBadge.classList.add('ots-status-published');
}
} else {
bar.classList.remove('ots-state-readonly');
if (statusBadge) {
statusBadge.textContent = ' {{ "Draft" | trans }} ';
statusBadge.classList.remove('ots-status-published');
}
}
}
// ── Detect initial state from data attributes ─────────────
// publishedLayoutId === layoutId → viewing the published version → read-only
// publishedLayoutId !== layoutId → viewing a draft copy → editable
function detectInitialState() {
var editorEl = document.getElementById('layout-editor');
if (!editorEl) return;
var layoutId = editorEl.getAttribute('data-layout-id');
var publishedId = editorEl.getAttribute('data-published-layout-id');
if (publishedId && publishedId !== '0' && publishedId === layoutId) {
setBarState('readonly');
} else {
setBarState('draft');
}
}
// ── Wait for the React editor to signal it's ready ────────
if (document.body.classList.contains('editor-opened')) {
detectInitialState();
} else {
var initObs = new MutationObserver(function() {
if (document.body.classList.contains('editor-opened')) {
initObs.disconnect();
detectInitialState();
}
});
initObs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
}
// ── Button: Save ──────────────────────────────────────────
// Xibo auto-saves widget/region changes via the history manager.
// The save button's job is to flush any pending properties panel form.
saveBtn.addEventListener('click', function() {
if (saveBtn.classList.contains('ots-btn-loading')) return;
try {
if (typeof lD !== 'undefined' && lD.propertiesPanel) {
lD.propertiesPanel.save( { target: lD.selectedObject });
}
} catch(e) { /* silent */ }
});
// ── Button: Publish ───────────────────────────────────────
publishBtn.addEventListener('click', function() {
if (publishBtn.classList.contains('ots-btn-loading')) return;
try {
if (typeof lD !== 'undefined' && typeof lD.showPublishScreen === 'function') {
lD.showPublishScreen();
}
} catch(e) { /* silent */ }
});
// ── Button: Checkout ──────────────────────────────────────
// Use urlsForApi (set by editorVars.twig) so the URL is always correct
// regardless of any sub-path the CMS is hosted under.
checkoutBtn.addEventListener('click', function() {
// Delegate to Xibo's own checkout implementation, which handles
// the AJAX call and redirect internally using urlsForApi.
if (typeof lD === 'undefined' || !lD.layout ||
typeof lD.layout.checkout !== 'function') {
toastr.error('Editor not ready. Please refresh and try again.');
return;
}
lD.layout.checkout();
});
// ── Button: Discard ───────────────────────────────────────
// We handle this manually so we can redirect back to the custom app
// (returnUrl) instead of letting Xibo redirect to its own layouts page.
if (discardBtn) {
discardBtn.addEventListener('click', function() {
if (discardBtn.classList.contains('ots-btn-loading')) return;
if (!confirm(' {{ "Discard this draft and revert to the published version?" | trans }} ')) return;
var editorEl = document.getElementById('layout-editor');
if (!editorEl) return;
// Discard endpoint takes the published (parent) layout ID.
var publishedId = editorEl.getAttribute('data-published-layout-id');
if (!publishedId) return;
if (typeof urlsForApi === 'undefined' ||
!urlsForApi.layout || !urlsForApi.layout.discard) return;
discardBtn.classList.add('ots-btn-loading');
discardBtn.disabled = true;
$.ajax( {
type: urlsForApi.layout.discard.type || 'PUT',
url: urlsForApi.layout.discard.url.replace(':id', publishedId),
success: function() {
// Redirect to returnUrl (same validation as Back button)
var rawReturn = new URLSearchParams(window.location.search).get('returnUrl');
var safeReturn = (rawReturn &&
rawReturn.charAt(0) === '/' &&
rawReturn.charAt(1) !== '/' &&
rawReturn.indexOf('://') === -1) ? rawReturn : null;
window.location.href = safeReturn || '/';
},
error: function(xhr) {
discardBtn.classList.remove('ots-btn-loading');
discardBtn.disabled = false;
try {
var err = xhr.responseJSON || JSON.parse(xhr.responseText);
toastr.error(err.message || ' {{ "Discard failed" | trans }} ');
} catch(e) {
toastr.error(' {{ "Discard failed" | trans }} ');
}
}
});
});
}
// ── Button: Interactive mode toggle ───────────────────────
if (interactiveBtn) {
interactiveBtn.addEventListener('click', function() {
try {
if (typeof lD !== 'undefined' && typeof lD.toggleInteractiveMode === 'function') {
lD.toggleInteractiveMode(!lD.interactiveMode);
}
} catch(e) { /* silent */ }
});
}
// ── Button: Schedule ──────────────────────────────────────
if (scheduleBtn) {
scheduleBtn.addEventListener('click', function() {
if (scheduleBtn.classList.contains('ots-btn-loading')) return;
try {
if (typeof lD !== 'undefined' && typeof lD.showScheduleScreen === 'function') {
lD.showScheduleScreen();
}
} catch(e) { /* silent */ }
});
}
// ── Button: Unlock ────────────────────────────────────────
if (unlockBtn) {
unlockBtn.addEventListener('click', function() {
try {
if (typeof lD !== 'undefined' && typeof lD.showUnlockScreen === 'function') {
lD.showUnlockScreen();
}
} catch(e) { /* silent */ }
});
}
// ── Watch #layout-editor classList for interactive-mode and locked state ──
(function() {
var editorEl = document.getElementById('layout-editor');
if (!editorEl) return;
new MutationObserver(function() {
// Sync interactive button visual toggle state
if (interactiveBtn) {
interactiveBtn.classList.toggle(
'ots-btn-active',
editorEl.classList.contains('interactive-mode') ||
editorEl.classList.contains('interactive-edit-widget-mode')
);
}
// Show Unlock button when lD adds 'locked' + 'locked-for-user' classes
bar.classList.toggle('ots-bar-locked', editorEl.classList.contains('locked-for-user'));
}).observe(editorEl, { attributes: true, attributeFilter: ['class'] });
})();
// ── AJAX intercepts: auto-update bar state ────────────────
$(document).ajaxComplete(function(event, xhr, settings) {
if (!settings || !settings.url) return;
try {
var response = xhr.responseJSON || { };
if (!response.success) return;
var url = settings.url;
// Layout draft save (PUT /layout/ { id}) → flash Save button
if (settings.type === 'PUT' && /\/layout\/\d+(?:$|\?|\/(?!publish|checkout|discard|delete))/.test(url)) {
saveBtn.classList.add('ots-btn-saved');
setTimeout(function() {
saveBtn.classList.remove('ots-btn-saved');
}, 2000);
}
// Publish → layout is now the published version → read-only
if (/\/layout\/publish\/\d+/.test(url)) {
setBarState('readonly');
}
// Checkout → a new draft is now active → editable
if (/\/layout\/checkout\/\d+/.test(url)) {
setBarState('draft');
}
} catch(e) { /* silent */ }
});
})();
</script>
2026-04-01 20:58:23 -04:00
{% endblock %}