Add layout designer and welcome page templates with embed support
- Introduced a new layout designer page template that supports an embeddable mode for integration within iframes in external applications. This includes hiding CMS navigation and sending postMessage events to the parent window. - Added a new welcome page template that serves as an onboarding guide for users, featuring step-by-step instructions for connecting displays, uploading content, designing layouts, and scheduling content. - Included CSS styles for both templates to enhance the user interface and experience. - Implemented JavaScript functionality for live statistics on the welcome page, fetching counts for displays, media files, layouts, and schedules.
This commit is contained in:
87
.github/copilot-instructions.md
vendored
Normal file
87
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
# OTS Signs Theme — Copilot Instructions
|
||||||
|
|
||||||
|
Custom Xibo CMS theme for OTS Signs digital signage service. Built with Twig templates, vanilla CSS, and vanilla JS. No build pipeline — all assets are inlined at runtime.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
- **Template engine**: Twig, inheriting from Xibo core's `base.twig`
|
||||||
|
- **Entry point**: `ots-signs/views/authed.twig` — wraps all authenticated pages (sidebar + topbar + content)
|
||||||
|
- **CSS delivery**: CSS files are **inlined** inside `<style nonce="...">` tags via `theme-javascript.twig`, not served as static files. This is intentional — it avoids MIME routing issues under the `custom/` path and ensures CSP nonce compliance.
|
||||||
|
- **JS delivery**: Same inline strategy via `theme-scripts.twig`
|
||||||
|
- **Config**: `ots-signs/config.php` — sets theme name, URLs, feature flags
|
||||||
|
|
||||||
|
### Twig inheritance
|
||||||
|
```
|
||||||
|
base.twig (Xibo core)
|
||||||
|
├── authed.twig ← most pages extend this
|
||||||
|
│ ├── dashboard-icon-page.twig
|
||||||
|
│ ├── layout-page.twig
|
||||||
|
│ └── [40+ admin page templates]
|
||||||
|
└── login.twig
|
||||||
|
```
|
||||||
|
|
||||||
|
Partials included in `authed.twig`: `authed-sidebar.twig`, `authed-topbar.twig`, `authed-user-menu.twig`, `authed-notification-drawer.twig`
|
||||||
|
|
||||||
|
## Key Xibo Template APIs
|
||||||
|
|
||||||
|
These are available in all authenticated Twig templates:
|
||||||
|
|
||||||
|
```twig
|
||||||
|
{# Permission-gated rendering #}
|
||||||
|
{% if currentUser.featureEnabled("layout.view") %}...{% endif %}
|
||||||
|
|
||||||
|
{# User preferences #}
|
||||||
|
{% set navPos = currentUser.getOptionValue("navigationMenuPosition", "vertical") %}
|
||||||
|
|
||||||
|
{# Theme helpers #}
|
||||||
|
{{ theme.uri("css/override.css") }}
|
||||||
|
{{ theme.getSetting("DEFAULT_VALUE", fallback) }}
|
||||||
|
{{ url_for("routeName") }}
|
||||||
|
{{ trans("string") }}
|
||||||
|
```
|
||||||
|
|
||||||
|
## CSS Architecture
|
||||||
|
|
||||||
|
Primary file: `ots-signs/css/override.css`
|
||||||
|
|
||||||
|
- Primary color: `#e87800` (orange), exposed as `--primary-color`
|
||||||
|
- Dark background: `#0f172a`, surface: `#1e293b`
|
||||||
|
- Sidebar: 256px expanded / 64px collapsed (icon-only), controlled via CSS vars
|
||||||
|
- `override-dark.css` handles component-level dark mode, floating menus, DataTable contrast
|
||||||
|
|
||||||
|
Do **not** add a build step (Sass, PostCSS, etc.) without first discussing it — the inline delivery mechanism requires plain CSS.
|
||||||
|
|
||||||
|
## JS Conventions
|
||||||
|
|
||||||
|
Single file: `ots-signs/js/theme.js` (440+ lines)
|
||||||
|
|
||||||
|
- IIFE pattern — no module bundler
|
||||||
|
- Uses `localStorage` for theme (`ots-theme`) and sidebar state (`ots-sidebar-collapsed`)
|
||||||
|
- MutationObserver watches for Xibo's layout editor modal, sets `body.ots-playlist-editor-active`
|
||||||
|
- Mobile breakpoint: `768px`
|
||||||
|
|
||||||
|
## Deployment
|
||||||
|
|
||||||
|
Theme lives at: `web/theme/custom/otssigns-beta/` inside the Xibo CMS installation.
|
||||||
|
The `view_path` in `config.php` must point to the absolute path of the `views/` directory.
|
||||||
|
|
||||||
|
## Common UI Patterns
|
||||||
|
|
||||||
|
| Pattern | Key class/element |
|
||||||
|
|---------|------------------|
|
||||||
|
| Icon dashboard cards | `.icon-dash-card` with `--blue`, `--green`, `--purple`, `--teal` color vars |
|
||||||
|
| Data grids | `.XiboGrid` wrapper → DataTables (managed by Xibo core) |
|
||||||
|
| Filter panels | `.XiboFilter` with tab-based advanced filter |
|
||||||
|
| Page header | `.page-header` with `<h1>` + subtitle |
|
||||||
|
| Toast/alerts | Bootstrap `.alert-danger/warning/success/info` |
|
||||||
|
| Forms | `inline.*()` macro system from Xibo core |
|
||||||
|
|
||||||
|
## What This Repo Does NOT Have
|
||||||
|
|
||||||
|
- No `package.json`, no npm/yarn/pnpm
|
||||||
|
- No webpack, Vite, Gulp, or Parcel
|
||||||
|
- No TypeScript — plain JS only
|
||||||
|
- No PHP unit tests in this repo (Xibo core testing is separate)
|
||||||
|
- No CI/CD config
|
||||||
|
|
||||||
|
Don't suggest adding any of the above unless the user explicitly asks.
|
||||||
2
manual/en/img/test.png
Normal file
2
manual/en/img/test.png
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
‰PNG
|
||||||
|
|
||||||
|
After Width: | Height: | Size: 8 B |
580
ots-signs/views/layout-designer-page.twig
Normal file
580
ots-signs/views/layout-designer-page.twig
Normal file
@@ -0,0 +1,580 @@
|
|||||||
|
{#
|
||||||
|
/**
|
||||||
|
* OTS Signs — Layout Designer Page (with embed mode support)
|
||||||
|
*
|
||||||
|
* Overrides the Xibo core layout-designer-page.twig to add an embeddable
|
||||||
|
* layout editor mode for use inside an iframe in external applications.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* Normal: /layout/designer/{layoutId}
|
||||||
|
* Embed: /layout/designer/{layoutId}?embed=1
|
||||||
|
*
|
||||||
|
* In embed mode:
|
||||||
|
* - All CMS navigation is hidden (sidebar, topbar, help pane)
|
||||||
|
* - Back/Exit buttons in the editor toolbar are hidden
|
||||||
|
* - 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 %}
|
||||||
|
|
||||||
|
{% block title %}{{ "Layout Editor"|trans }} | {% endblock %}
|
||||||
|
|
||||||
|
{% set hideNavigation = "1" %}
|
||||||
|
{% set forceHide = true %}
|
||||||
|
|
||||||
|
{% block headContent %}
|
||||||
|
{{ parent() }}
|
||||||
|
|
||||||
|
{# Embed mode: early body class to prevent FOUC #}
|
||||||
|
<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');
|
||||||
|
}
|
||||||
|
} catch(e) {
|
||||||
|
// Cross-origin iframe detection may throw — treat as embed
|
||||||
|
document.documentElement.classList.add('ots-embed-mode');
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style nonce="{{ cspNonce }}">
|
||||||
|
/* ── 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Hide floating page actions (notification bell, user menu) */
|
||||||
|
.ots-embed-mode .ots-page-actions {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Remove content wrapper padding/margins for full-bleed editor */
|
||||||
|
.ots-embed-mode #content-wrapper {
|
||||||
|
padding: 0 !important;
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block pageContent %}
|
||||||
|
|
||||||
|
<!-- Editor structure -->
|
||||||
|
<div id="layout-editor" data-published-layout-id="{{ publishedLayoutId }}" data-layout-id="{{ layout.layoutId }}" data-layout-help={{ help }}></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/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;
|
||||||
|
|
||||||
|
// TODO: For production, restrict targetOrigin to your app's domain
|
||||||
|
var targetOrigin = '*';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// ── Notify parent when editor is ready ──────────────────
|
||||||
|
// Wait for the layout editor to initialize (it adds .editor-opened to body)
|
||||||
|
var readyObserver = new MutationObserver(function(mutations) {
|
||||||
|
if (document.body.classList.contains('editor-opened')) {
|
||||||
|
sendToParent('xibo:editor:ready', {});
|
||||||
|
readyObserver.disconnect();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (document.body.classList.contains('editor-opened')) {
|
||||||
|
// Already ready (unlikely but safe)
|
||||||
|
sendToParent('xibo:editor:ready', {});
|
||||||
|
} else {
|
||||||
|
readyObserver.observe(document.body, { attributes: true, attributeFilter: ['class'] });
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
648
ots-signs/views/welcome-page.twig
Normal file
648
ots-signs/views/welcome-page.twig
Normal file
@@ -0,0 +1,648 @@
|
|||||||
|
{#
|
||||||
|
/**
|
||||||
|
* Copyright (C) 2026 OTS Signs
|
||||||
|
*
|
||||||
|
* Welcome / onboarding page for OTS Signs.
|
||||||
|
*
|
||||||
|
* Overrides Xibo's default welcome-page.twig. All cards are rendered
|
||||||
|
* server-side in Twig — no dependency on the Xibo compiled JS bundle.
|
||||||
|
* Inline JS populates live stat counts via the existing fetchCount pattern.
|
||||||
|
*/
|
||||||
|
#}
|
||||||
|
{% extends "authed.twig" %}
|
||||||
|
|
||||||
|
{% block title %}{{ "Welcome"|trans }} | {% endblock %}
|
||||||
|
|
||||||
|
{% block pageContent %}
|
||||||
|
<style nonce="{{ cspNonce }}">
|
||||||
|
/* ── Welcome page layout ─────────────────────────────────────────────── */
|
||||||
|
.ots-welcome-page {
|
||||||
|
padding: 24px 32px 48px;
|
||||||
|
max-width: 1100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Hero ────────────────────────────────────────────────────────────── */
|
||||||
|
.ots-welcome-hero {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 40px;
|
||||||
|
padding: 40px 48px;
|
||||||
|
background: linear-gradient(135deg, var(--color-surface) 0%, #162035 100%);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 16px;
|
||||||
|
margin-bottom: 40px;
|
||||||
|
overflow: hidden;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: -60px;
|
||||||
|
right: -60px;
|
||||||
|
width: 280px;
|
||||||
|
height: 280px;
|
||||||
|
background: radial-gradient(circle, rgba(232, 120, 0, 0.12) 0%, transparent 70%);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-text h1 {
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-text h1 span {
|
||||||
|
color: #e87800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-text p {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0 0 24px;
|
||||||
|
max-width: 520px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-btn-primary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: #e87800;
|
||||||
|
color: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: background 0.15s, transform 0.1s;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-btn-primary:hover,
|
||||||
|
.ots-welcome-btn-primary:focus {
|
||||||
|
background: #c96800;
|
||||||
|
color: #fff;
|
||||||
|
text-decoration: none;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-btn-secondary {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: border-color 0.15s, color 0.15s, background 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-btn-secondary:hover,
|
||||||
|
.ots-welcome-btn-secondary:focus {
|
||||||
|
border-color: #e87800;
|
||||||
|
color: #e87800;
|
||||||
|
background: rgba(232, 120, 0, 0.06);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
|
width: 140px;
|
||||||
|
height: 140px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(232, 120, 0, 0.1);
|
||||||
|
border: 1px solid rgba(232, 120, 0, 0.2);
|
||||||
|
border-radius: 24px;
|
||||||
|
color: #e87800;
|
||||||
|
font-size: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Section heading ─────────────────────────────────────────────────── */
|
||||||
|
.ots-welcome-section-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Step cards ──────────────────────────────────────────────────────── */
|
||||||
|
.ots-welcome-steps {
|
||||||
|
margin-bottom: 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-step-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 24px 28px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-step-card:hover {
|
||||||
|
border-color: rgba(232, 120, 0, 0.35);
|
||||||
|
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-num {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: var(--color-surface-elevated);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 700;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 12px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 22px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-icon--green { background: rgba(16, 185, 129, 0.15); color: #10b981; }
|
||||||
|
.ots-step-icon--blue { background: rgba(59, 130, 246, 0.15); color: #3b82f6; }
|
||||||
|
.ots-step-icon--purple { background: rgba(124, 58, 237, 0.15); color: #7c3aed; }
|
||||||
|
.ots-step-icon--orange { background: rgba(232, 120, 0, 0.15); color: #e87800; }
|
||||||
|
|
||||||
|
.ots-step-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-desc {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0 0 12px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-links {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-links a {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
padding: 5px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-link-primary {
|
||||||
|
background: rgba(232, 120, 0, 0.15);
|
||||||
|
color: #e87800;
|
||||||
|
border: 1px solid rgba(232, 120, 0, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-link-primary:hover,
|
||||||
|
.ots-step-link-primary:focus {
|
||||||
|
background: rgba(232, 120, 0, 0.25);
|
||||||
|
color: #e87800;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-link-secondary {
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-link-secondary:hover,
|
||||||
|
.ots-step-link-secondary:focus {
|
||||||
|
color: var(--color-text-secondary);
|
||||||
|
border-color: var(--color-text-tertiary);
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-stat {
|
||||||
|
flex-shrink: 0;
|
||||||
|
text-align: right;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding-left: 16px;
|
||||||
|
min-width: 64px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-stat-num {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Resource cards ──────────────────────────────────────────────────── */
|
||||||
|
.ots-welcome-resources {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-resource-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 20px 22px;
|
||||||
|
background: var(--color-surface);
|
||||||
|
border: 1px solid var(--color-border);
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: border-color 0.15s, box-shadow 0.15s;
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-resource-card:hover {
|
||||||
|
border-color: rgba(232, 120, 0, 0.35);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.18);
|
||||||
|
text-decoration: none;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-resource-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: rgba(232, 120, 0, 0.12);
|
||||||
|
color: #e87800;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 20px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-resource-body {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-resource-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--color-text-primary);
|
||||||
|
margin: 0 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-resource-desc {
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--color-text-tertiary);
|
||||||
|
margin: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Light mode overrides ────────────────────────────────────────────── */
|
||||||
|
.ots-light-mode .ots-welcome-hero {
|
||||||
|
background: linear-gradient(135deg, #f8fafc 0%, #f0f6ff 100%);
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-light-mode .ots-welcome-step-card,
|
||||||
|
.ots-light-mode .ots-welcome-resource-card {
|
||||||
|
background: #fff;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-light-mode .ots-step-num {
|
||||||
|
background: #f1f5f9;
|
||||||
|
border-color: #e2e8f0;
|
||||||
|
color: #64748b;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Responsive ──────────────────────────────────────────────────────── */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.ots-welcome-page {
|
||||||
|
padding: 16px 16px 40px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero {
|
||||||
|
flex-direction: column;
|
||||||
|
padding: 28px 24px;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-icon {
|
||||||
|
width: 80px;
|
||||||
|
height: 80px;
|
||||||
|
font-size: 36px;
|
||||||
|
border-radius: 16px;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-hero-text h1 {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-step-card {
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-step-stat {
|
||||||
|
text-align: left;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding-left: 0;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--color-border);
|
||||||
|
flex-direction: row;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ots-welcome-resources {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<div class="ots-welcome-page">
|
||||||
|
|
||||||
|
{# ── Hero ─────────────────────────────────────────────────────────── #}
|
||||||
|
{% set productName = theme.getThemeConfig('theme_title') %}
|
||||||
|
<div class="ots-welcome-hero">
|
||||||
|
<div class="ots-welcome-hero-text">
|
||||||
|
<h1>{% trans %}Welcome to <span>{{ productName }}</span>{% endtrans %}</h1>
|
||||||
|
<p>{% trans %}Your digital signage control centre. Connect your displays, upload content, design layouts, and schedule what plays — all from one place.{% endtrans %}</p>
|
||||||
|
<div class="ots-welcome-hero-actions">
|
||||||
|
<a href="{{ helpService.getLandingPage() }}" target="_blank" rel="noopener noreferrer" class="ots-welcome-btn-primary">
|
||||||
|
<i class="fa fa-book" aria-hidden="true"></i>
|
||||||
|
{% trans "View Documentation" %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for("home") }}" class="ots-welcome-btn-secondary">
|
||||||
|
<i class="fa fa-th-large" aria-hidden="true"></i>
|
||||||
|
{% trans "Go to Dashboard" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-welcome-hero-icon" aria-hidden="true">
|
||||||
|
<i class="fa fa-tv"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# ── Get Started steps ────────────────────────────────────────────── #}
|
||||||
|
<div class="ots-welcome-steps">
|
||||||
|
<p class="ots-welcome-section-title">{% trans "Get Started" %}</p>
|
||||||
|
|
||||||
|
{% if currentUser.featureEnabled("displays.view") %}
|
||||||
|
<div class="ots-welcome-step-card">
|
||||||
|
<div class="ots-step-left">
|
||||||
|
<div class="ots-step-num">1</div>
|
||||||
|
<div class="ots-step-icon ots-step-icon--green">
|
||||||
|
<i class="fa fa-desktop" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-body">
|
||||||
|
<div class="ots-step-title">{% trans "Connect a Display" %}</div>
|
||||||
|
<p class="ots-step-desc">{% trans %}Install the player app on a screen, then authorise it here. Once connected, your display is ready to receive scheduled content.{% endtrans %}</p>
|
||||||
|
<div class="ots-step-links">
|
||||||
|
<a href="{{ url_for("display.view") }}" class="ots-step-link-primary">
|
||||||
|
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Manage Displays" %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ helpService.getLandingPage() }}displays.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
|
||||||
|
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Displays Guide" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-stat">
|
||||||
|
<span class="ots-step-stat-num" id="ots-wc-stat-displays">—</span>
|
||||||
|
<span class="ots-step-stat-label">{% trans "Displays" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if currentUser.featureEnabled("library.view") %}
|
||||||
|
<div class="ots-welcome-step-card">
|
||||||
|
<div class="ots-step-left">
|
||||||
|
<div class="ots-step-num">2</div>
|
||||||
|
<div class="ots-step-icon ots-step-icon--blue">
|
||||||
|
<i class="fa fa-image" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-body">
|
||||||
|
<div class="ots-step-title">{% trans "Upload Content" %}</div>
|
||||||
|
<p class="ots-step-desc">{% trans %}Add images, videos, and other media files to your library. Supported formats include JPEG, PNG, MP4 and more.{% endtrans %}</p>
|
||||||
|
<div class="ots-step-links">
|
||||||
|
<a href="{{ url_for("library.view") }}" class="ots-step-link-primary">
|
||||||
|
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Open Library" %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ helpService.getLandingPage() }}media_library.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
|
||||||
|
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Library Guide" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-stat">
|
||||||
|
<span class="ots-step-stat-num" id="ots-wc-stat-media">—</span>
|
||||||
|
<span class="ots-step-stat-label">{% trans "Media Files" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if currentUser.featureEnabled("layout.view") %}
|
||||||
|
<div class="ots-welcome-step-card">
|
||||||
|
<div class="ots-step-left">
|
||||||
|
<div class="ots-step-num">3</div>
|
||||||
|
<div class="ots-step-icon ots-step-icon--purple">
|
||||||
|
<i class="fa fa-columns" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-body">
|
||||||
|
<div class="ots-step-title">{% trans "Design a Layout" %}</div>
|
||||||
|
<p class="ots-step-desc">{% trans %}Create multi-zone screen layouts using the visual editor. Combine images, videos, text, and data widgets into a polished design.{% endtrans %}</p>
|
||||||
|
<div class="ots-step-links">
|
||||||
|
<a href="{{ url_for("layout.view") }}" class="ots-step-link-primary">
|
||||||
|
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Manage Layouts" %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ helpService.getLandingPage() }}layouts_editor.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
|
||||||
|
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Layout Editor Guide" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-stat">
|
||||||
|
<span class="ots-step-stat-num" id="ots-wc-stat-layouts">—</span>
|
||||||
|
<span class="ots-step-stat-label">{% trans "Layouts" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if currentUser.featureEnabled("schedule.view") %}
|
||||||
|
<div class="ots-welcome-step-card">
|
||||||
|
<div class="ots-step-left">
|
||||||
|
<div class="ots-step-num">4</div>
|
||||||
|
<div class="ots-step-icon ots-step-icon--orange">
|
||||||
|
<i class="fa fa-calendar" aria-hidden="true"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-body">
|
||||||
|
<div class="ots-step-title">{% trans "Schedule Content" %}</div>
|
||||||
|
<p class="ots-step-desc">{% trans %}Assign layouts and campaigns to displays on a timed schedule. Set start and end times, repeat rules, and priorities.{% endtrans %}</p>
|
||||||
|
<div class="ots-step-links">
|
||||||
|
<a href="{{ url_for("schedule.view") }}" class="ots-step-link-primary">
|
||||||
|
<i class="fa fa-arrow-right" aria-hidden="true"></i> {% trans "Open Schedule" %}
|
||||||
|
</a>
|
||||||
|
<a href="{{ helpService.getLandingPage() }}displays_configuration.html" target="_blank" rel="noopener noreferrer" class="ots-step-link-secondary">
|
||||||
|
<i class="fa fa-external-link" aria-hidden="true"></i> {% trans "Scheduling Guide" %}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="ots-step-stat">
|
||||||
|
<span class="ots-step-stat-num" id="ots-wc-stat-schedules">—</span>
|
||||||
|
<span class="ots-step-stat-label">{% trans "Schedules" %}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>{# /ots-welcome-steps #}
|
||||||
|
|
||||||
|
{# ── Resources ────────────────────────────────────────────────────── #}
|
||||||
|
<p class="ots-welcome-section-title">{% trans "Resources" %}</p>
|
||||||
|
<div class="ots-welcome-resources">
|
||||||
|
|
||||||
|
<a href="{{ helpService.getLandingPage() }}" target="_blank" rel="noopener noreferrer" class="ots-welcome-resource-card">
|
||||||
|
<div class="ots-resource-icon" aria-hidden="true">
|
||||||
|
<i class="fa fa-book"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ots-resource-body">
|
||||||
|
<div class="ots-resource-title">{% trans "User Manual" %}</div>
|
||||||
|
<p class="ots-resource-desc">{% trans "Step-by-step guides for every feature in OTS Signs." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<a href="{{ theme.getSetting('SUPPORT_ADDRESS', 'https://ots-signs.com/support') }}" target="_blank" rel="noopener noreferrer" class="ots-welcome-resource-card">
|
||||||
|
<div class="ots-resource-icon" aria-hidden="true">
|
||||||
|
<i class="fa fa-life-ring"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ots-resource-body">
|
||||||
|
<div class="ots-resource-title">{% trans "Support" %}</div>
|
||||||
|
<p class="ots-resource-desc">{% trans "Get help from the OTS Signs support team." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
|
||||||
|
{% if currentUser.isSuperAdmin() %}
|
||||||
|
<a href="{{ url_for("settings") }}" class="ots-welcome-resource-card">
|
||||||
|
<div class="ots-resource-icon" aria-hidden="true">
|
||||||
|
<i class="fa fa-cog"></i>
|
||||||
|
</div>
|
||||||
|
<div class="ots-resource-body">
|
||||||
|
<div class="ots-resource-title">{% trans "CMS Settings" %}</div>
|
||||||
|
<p class="ots-resource-desc">{% trans "Configure your CMS installation and storage options." %}</p>
|
||||||
|
</div>
|
||||||
|
</a>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
</div>{# /ots-welcome-resources #}
|
||||||
|
|
||||||
|
</div>{# /ots-welcome-page #}
|
||||||
|
{% endblock %}
|
||||||
|
|
||||||
|
{% block javaScript %}
|
||||||
|
<script type="text/javascript" nonce="{{ cspNonce }}">
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var $ = window.jQuery;
|
||||||
|
if (!$) return;
|
||||||
|
|
||||||
|
function fetchCount(url, elId) {
|
||||||
|
$.ajax({
|
||||||
|
url: url,
|
||||||
|
type: 'GET',
|
||||||
|
dataType: 'json',
|
||||||
|
data: { start: 0, length: 1 },
|
||||||
|
success: function (resp) {
|
||||||
|
var count = 0;
|
||||||
|
if (resp && typeof resp.recordsTotal !== 'undefined') {
|
||||||
|
count = resp.recordsTotal;
|
||||||
|
} else if (resp && Array.isArray(resp.data)) {
|
||||||
|
count = resp.data.length;
|
||||||
|
} else if (resp && typeof resp.total !== 'undefined') {
|
||||||
|
count = resp.total;
|
||||||
|
}
|
||||||
|
var el = document.getElementById(elId);
|
||||||
|
if (el) el.textContent = count.toLocaleString();
|
||||||
|
},
|
||||||
|
error: function () {
|
||||||
|
var el = document.getElementById(elId);
|
||||||
|
if (el) el.textContent = '—';
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
$(function () {
|
||||||
|
{% if currentUser.featureEnabled("displays.view") %}
|
||||||
|
fetchCount('{{ url_for("display.search") }}', 'ots-wc-stat-displays');
|
||||||
|
{% endif %}
|
||||||
|
{% if currentUser.featureEnabled("library.view") %}
|
||||||
|
fetchCount('{{ url_for("library.search") }}', 'ots-wc-stat-media');
|
||||||
|
{% endif %}
|
||||||
|
{% if currentUser.featureEnabled("layout.view") %}
|
||||||
|
fetchCount('{{ url_for("layout.search") }}', 'ots-wc-stat-layouts');
|
||||||
|
{% endif %}
|
||||||
|
{% if currentUser.featureEnabled("schedule.view") %}
|
||||||
|
fetchCount('{{ url_for("schedule.search") }}', 'ots-wc-stat-schedules');
|
||||||
|
{% endif %}
|
||||||
|
});
|
||||||
|
}());
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
Reference in New Issue
Block a user