Compare commits

...
5 Commits
Author SHA1 Message Date
lzstealth a4936b6a20 Keep empty region previews blank
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m14s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s
2026-09-14 00:22:39 +01:00
lzstealth 1e317997c4 Document time date placeholder transforms
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m15s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 35s
2026-09-14 00:07:49 +01:00
lzstealth 222162df57 Fix player cached images and blank time dates
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m50s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 33s
2026-09-13 23:56:08 +01:00
lzstealth ac18644fe8 Release 2.13.3 2026-09-13 23:47:11 +01:00
lzstealth fea29f6a3c Release v2.13.2
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m18s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s
2026-09-11 21:18:54 +01:00
30 changed files with 219 additions and 83 deletions
+18
View File
@@ -2,6 +2,24 @@
All notable changes to this project will be documented in this file. All notable changes to this project will be documented in this file.
## 2.13.3 - 2026-09-13
### Fixed
- Fixed the template background image media picker Upload media button so it opens the file selector.
- Fixed the template designer empty background message moving when the background image is removed.
- Fixed cached API and RSS images not being served by the player after being downloaded.
- Fixed blank time/date regions displaying the default clock format on the player.
- Fixed the time/date placeholder popup omitting text and math transform help.
- Fixed empty region previews displaying type labels instead of remaining blank.
- Fixed empty weather regions displaying the default weather summary.
## 2.13.2 - 2026-09-11
### Changed
- Refined the Media Library upload flow with a compact Add media modal, clearer toolbar controls, full-height scrolling, and more obvious selection feedback for media that is already in use.
## 2.13.1 - 2026-09-11 ## 2.13.1 - 2026-09-11
### Added ### Added
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.13.1", "version": "2.13.3",
"private": false, "private": false,
"description": "Pulse Signage player application bundle", "description": "Pulse Signage player application bundle",
"engines": { "engines": {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-web", "name": "pulse-signage-web",
"version": "2.13.1", "version": "2.13.3",
"private": false, "private": false,
"description": "Pulse Signage web and bridge application bundle", "description": "Pulse Signage web and bridge application bundle",
"engines": { "engines": {
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.13.1", "version": "2.13.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.13.1", "version": "2.13.3",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^149.0.0", "@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1", "animate.css": "^4.1.1",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.13.1", "version": "2.13.3",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
-4
View File
@@ -489,10 +489,6 @@ function createLocalControlService(options) {
} }
} }
app.use('/media/player-cache', function (_req, res) {
return res.sendStatus(404);
});
app.get('/local-control', function (req, res) { app.get('/local-control', function (req, res) {
res.set('Cache-Control', 'no-store'); res.set('Cache-Control', 'no-store');
res.type('html').send(renderPageV2()); res.type('html').send(renderPageV2());
+4 -5
View File
@@ -2,7 +2,6 @@
var registry = window.pulsePlayerRegionTypes; var registry = window.pulsePlayerRegionTypes;
var placeholderUtils = window.placeholderUtils || {}; var placeholderUtils = window.placeholderUtils || {};
var DEFAULT_FORMAT = '{{hh}}:{{mm}}';
var DEFAULT_STYLE = { var DEFAULT_STYLE = {
font_family: 'Arial', font_family: 'Arial',
font_size: 32, font_size: 32,
@@ -204,7 +203,7 @@ function getTimeDateFormattedParts(timeZone, date) {
dddd: toTitleCase(getPart(weekdayLong, 'weekday')), dddd: toTitleCase(getPart(weekdayLong, 'weekday')),
MMM: toTitleCase(getPart(monthShort, 'month')), MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')), MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')), a: String(getPart(ampm, 'dayPeriod') || '').toLowerCase(),
tz: getPart(timezoneShort, 'timeZoneName'), tz: getPart(timezoneShort, 'timeZoneName'),
tz_long: resolvedTimeZone, tz_long: resolvedTimeZone,
date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'), date: getPart(numericParts, 'year') + '-' + getPart(numericParts, 'month') + '-' + getPart(numericParts, 'day'),
@@ -223,7 +222,7 @@ function resolveTimeDateTemplatePlaceholder(values, expression) {
} }
function renderTimeDateTemplate(format, timeZone, date) { function renderTimeDateTemplate(format, timeZone, date) {
var template = String(format || '').trim() || DEFAULT_FORMAT; var template = String(format || '').trim();
var values = getTimeDateFormattedParts(timeZone, date); var values = getTimeDateFormattedParts(timeZone, date);
return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) { return template.replace(/\{\{\s*([a-zA-Z0-9_.()\-]+)\s*\}\}/g, function (_match, key) {
return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || ''); return String(resolveTimeDateTemplatePlaceholder(values, key, { timeZone: timeZone }) || '');
@@ -243,7 +242,7 @@ function getTextStyle(regionContent, region) {
function renderTimeDateRegion(region, regionContent) { function renderTimeDateRegion(region, regionContent) {
var content = regionContent && typeof regionContent === 'object' ? regionContent : {}; var content = regionContent && typeof regionContent === 'object' ? regionContent : {};
var format = String(content.value !== undefined ? content.value : content.text || '').trim() || DEFAULT_FORMAT; var format = String(content.value !== undefined ? content.value : content.text || '').trim();
var timeZone = resolveTimeZone(content.timezone || content.time_zone || ''); var timeZone = resolveTimeZone(content.timezone || content.time_zone || '');
var style = getTextStyle(content, region); var style = getTextStyle(content, region);
var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
@@ -259,7 +258,7 @@ function updateTimeDateRegion(element) {
return; return;
} }
var format = String(element.dataset.timeDateFormat || '').trim() || DEFAULT_FORMAT; var format = String(element.dataset.timeDateFormat || '').trim();
var timeZone = String(element.dataset.timeDateTimezone || '').trim(); var timeZone = String(element.dataset.timeDateTimezone || '').trim();
var scaleWrapper = element.querySelector('.template-region-text-scale'); var scaleWrapper = element.querySelector('.template-region-text-scale');
if (!scaleWrapper) { if (!scaleWrapper) {
+2 -1
View File
@@ -69,6 +69,8 @@ function substituteWeatherVariables(html, value) {
} }
function renderWeatherRegion(region, regionContent) { function renderWeatherRegion(region, regionContent) {
var value = String(regionContent && regionContent.value || '');
if (!value.trim()) return '';
var location = getWeatherLocation(regionContent && regionContent.weather_location_id); var location = getWeatherLocation(regionContent && regionContent.weather_location_id);
var snapshot = location && location.responseJson && typeof location.responseJson === 'object' ? location.responseJson : null; var snapshot = location && location.responseJson && typeof location.responseJson === 'object' ? location.responseJson : null;
var width = Math.max(1, Math.round(Number(region && region.pixelWidth) || 1)); var width = Math.max(1, Math.round(Number(region && region.pixelWidth) || 1));
@@ -77,7 +79,6 @@ function renderWeatherRegion(region, regionContent) {
var style = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + scale + ');transform-origin:top left;overflow:hidden;'; var style = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + scale + ');transform-origin:top left;overflow:hidden;';
if (!location || !snapshot) return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + '"></div></div>'; if (!location || !snapshot) return '<div class="template-region weather" style="' + region.baseStyle + '"><div style="' + style + '"></div></div>';
var current = snapshot.current || {}; var current = snapshot.current || {};
var value = String(regionContent && regionContent.value || '');
if (value) { if (value) {
var weatherData = withWeatherUnits(snapshot, location); var weatherData = withWeatherUnits(snapshot, location);
var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32); var fontSize = Math.max(8, Number(regionContent && regionContent.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
+28 -2
View File
@@ -30,12 +30,16 @@
.media-gallery-toolbar { .media-gallery-toolbar {
display: flex; display: flex;
align-items: end; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 1rem; gap: 1rem;
margin-bottom: 1rem; margin-bottom: 1rem;
} }
.media-gallery-count {
flex: 0 0 auto;
}
.media-gallery-search { .media-gallery-search {
width: min(100%, 52rem); width: min(100%, 52rem);
} }
@@ -113,6 +117,28 @@
font-weight: 700; font-weight: 700;
} }
.media-gallery-card-selection-disabled {
opacity: .72;
}
.media-gallery-card-selection-disabled::before {
position: absolute;
top: .65rem;
left: .65rem;
z-index: 2;
padding: .25rem .5rem;
border-radius: .25rem;
background: var(--bs-secondary);
color: var(--bs-white);
content: 'In use';
font-size: .75rem;
font-weight: 700;
}
.media-gallery-selection-mode .media-gallery-card.media-gallery-card-selection-disabled {
cursor: not-allowed;
}
.media-gallery-selection-mode .media-gallery-card { .media-gallery-selection-mode .media-gallery-card {
cursor: pointer; cursor: pointer;
} }
@@ -4069,7 +4095,7 @@ table.table thead th.sort-desc .table-sort-indicator {
.media-gallery-scroll { .media-gallery-scroll {
flex: 1 1 auto; flex: 1 1 auto;
min-height: 0; min-height: 0;
max-height: max(12rem, calc(100dvh - 29rem)); max-height: none;
overflow-x: hidden; overflow-x: hidden;
overflow-y: auto; overflow-y: auto;
padding-right: .5rem; padding-right: .5rem;
+8 -1
View File
@@ -160,9 +160,16 @@
if (!activeButton) { if (!activeButton) {
return null; return null;
} }
var explicitInputId = activeButton.getAttribute('data-media-picker-file-input');
if (explicitInputId) {
var explicitInput = document.getElementById(explicitInputId);
if (explicitInput) {
return explicitInput;
}
}
var hiddenKey = activeButton.getAttribute('data-media-picker-hidden'); var hiddenKey = activeButton.getAttribute('data-media-picker-hidden');
if (hiddenKey) { if (hiddenKey) {
var inputFromHidden = document.getElementById(hiddenKey.replace(/^existing_/, '')); var inputFromHidden = document.getElementById(hiddenKey.replace(/^existing[-_]/, ''));
if (inputFromHidden) { if (inputFromHidden) {
return inputFromHidden; return inputFromHidden;
} }
+1 -1
View File
@@ -44,7 +44,7 @@
function renderPreview(region, value) { function renderPreview(region, value) {
var html = normalizePreviewValue(value !== undefined ? value : region).trim(); var html = normalizePreviewValue(value !== undefined ? value : region).trim();
if (!html) { if (!html) {
return '<div class="slide-preview-placeholder">HTML</div>'; return '';
} }
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) { if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<iframe class="slide-preview-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>'; return '<iframe class="slide-preview-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
+1 -1
View File
@@ -21,7 +21,7 @@
var content = value && typeof value === 'object' && value.value !== undefined ? value : null; var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
var src = String(content ? content.value : value || '').trim(); var src = String(content ? content.value : value || '').trim();
if (!src) { if (!src) {
return '<div class="slide-preview-placeholder">Image</div>'; return '';
} }
return '<img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />'; return '<img class="slide-preview-image" src="' + escapeHtml(src) + '" alt="" style="width:100%;height:100%;object-fit:contain;display:block;" />';
} }
+1 -1
View File
@@ -880,7 +880,7 @@
src = svgSrc || buildStyledQrSvgMarkup(value); src = svgSrc || buildStyledQrSvgMarkup(value);
} }
if (!src) { if (!src) {
return '<div class="slide-preview-placeholder">QR Code</div>'; return '';
} }
if (/^data:image\//i.test(src)) { if (/^data:image\//i.test(src)) {
return '<img class="slide-preview-qr-code" src="' + escapeHtml(src) + '" alt="QR code preview" style="' + imageStyle + '" />'; return '<img class="slide-preview-qr-code" src="' + escapeHtml(src) + '" alt="QR code preview" style="' + imageStyle + '" />';
+2 -2
View File
@@ -158,7 +158,7 @@
function getPreviewFallback(item) { function getPreviewFallback(item) {
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return '<div class="slide-preview-placeholder">RSS item</div>'; return '';
} }
var title = String(item.title || '').trim(); var title = String(item.title || '').trim();
@@ -171,7 +171,7 @@
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>'); summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
} }
if (!summary.length) { if (!summary.length) {
return '<div class="slide-preview-placeholder">RSS item</div>'; return '';
} }
return summary.join(''); return summary.join('');
} }
+4 -1
View File
@@ -11,7 +11,10 @@
function renderPreview(value, disableAudio) { function renderPreview(value, disableAudio) {
var content = value && typeof value === 'object' && value.value !== undefined ? value : null; var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
var src = String(content ? content.value : value || '').trim(); var src = String(content ? content.value : value || '').trim();
var label = src ? 'RTMP' : 'RTMP stream'; if (!src) {
return '';
}
var label = 'RTMP';
if (disableAudio) { if (disableAudio) {
label += ' (muted)'; label += ' (muted)';
} }
+1 -1
View File
@@ -29,7 +29,7 @@
var rawValue = value && typeof value === 'object' && value.value !== undefined ? value.value : value; var rawValue = value && typeof value === 'object' && value.value !== undefined ? value.value : value;
var raw = String(rawValue || ''); var raw = String(rawValue || '');
if (!raw) { if (!raw) {
return '<div class="slide-preview-placeholder">Empty text</div>'; return '';
} }
var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {}; var style = styleOrContext && styleOrContext.style ? styleOrContext.style : styleOrContext || {};
+5 -2
View File
@@ -155,7 +155,7 @@
dddd: toTitleCase(getPart(weekdayLong, 'weekday')), dddd: toTitleCase(getPart(weekdayLong, 'weekday')),
MMM: toTitleCase(getPart(monthShort, 'month')), MMM: toTitleCase(getPart(monthShort, 'month')),
MMMM: toTitleCase(getPart(monthLong, 'month')), MMMM: toTitleCase(getPart(monthLong, 'month')),
a: toTitleCase(getPart(ampm, 'dayPeriod')), a: String(getPart(ampm, 'dayPeriod') || '').toLowerCase(),
tz: getPart(getFormatter('tz-short:' + resolvedTimeZone, { tz: getPart(getFormatter('tz-short:' + resolvedTimeZone, {
timeZone: resolvedTimeZone, timeZone: resolvedTimeZone,
timeZoneName: 'short' timeZoneName: 'short'
@@ -296,7 +296,10 @@
var table = '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Placeholder</th><th>Output</th><th>Description</th></tr></thead><tbody>' + placeholders.map(function (item) { var table = '<div class="table-responsive"><table class="table table-sm align-middle mb-0"><thead><tr><th>Placeholder</th><th>Output</th><th>Description</th></tr></thead><tbody>' + placeholders.map(function (item) {
return '<tr><td><code>{{' + escapeHtml(item.token) + '}}</code></td><td>' + escapeHtml(item.output) + '</td><td>' + escapeHtml(item.description) + '</td></tr>'; return '<tr><td><code>{{' + escapeHtml(item.token) + '}}</code></td><td>' + escapeHtml(item.output) + '</td><td>' + escapeHtml(item.description) + '</td></tr>';
}).join('') + '</tbody></table></div>'; }).join('') + '</tbody></table></div>';
return window.placeholderInfo.render(regionId, 'Time and date placeholders', table); var transforms = '' +
(typeof window.placeholderInfo.renderTextTransforms === 'function' ? window.placeholderInfo.renderTextTransforms() : '') +
(typeof window.placeholderInfo.renderMathTransforms === 'function' ? window.placeholderInfo.renderMathTransforms() : '');
return window.placeholderInfo.render(regionId, 'Time and date placeholders', table + transforms);
} }
function renderEditorCard(context) { function renderEditorCard(context) {
+1 -1
View File
@@ -26,7 +26,7 @@
var content = value && typeof value === 'object' && value.value !== undefined ? value : null; var content = value && typeof value === 'object' && value.value !== undefined ? value : null;
var src = String(content ? content.value : value || '').trim(); var src = String(content ? content.value : value || '').trim();
if (!src) { if (!src) {
return '<div class="slide-preview-placeholder">Video</div>'; return '';
} }
return '<video class="slide-preview-video" src="' + escapeHtml(src) + '" autoplay loop muted playsinline preload="metadata" style="width:100%;height:100%;object-fit:contain;display:block;"></video>'; return '<video class="slide-preview-video" src="' + escapeHtml(src) + '" autoplay loop muted playsinline preload="metadata" style="width:100%;height:100%;object-fit:contain;display:block;"></video>';
} }
+3 -2
View File
@@ -148,10 +148,11 @@
function renderPreview(region, content, context) { function renderPreview(region, content, context) {
var locations = context && context.weatherLocations ? context.weatherLocations : []; var locations = context && context.weatherLocations ? context.weatherLocations : [];
var value = String(content && content.value || '');
if (!value.trim()) return '';
var location = getLocationById(content && content.weather_location_id, locations); var location = getLocationById(content && content.weather_location_id, locations);
var snapshot = getSnapshot(location); var snapshot = getSnapshot(location);
var value = String(content && content.value || ''); if (!location || !snapshot) return '';
if (!location || !snapshot) return '<div class="template-region weather"><div class="slide-preview-placeholder">Select a weather location with cached data</div></div>';
var weatherData = withWeatherUnits(snapshot, location); var weatherData = withWeatherUnits(snapshot, location);
if (value) { if (value) {
var fontSize = Math.max(8, Number(content && content.font_size || region && (region.font_size || region.fontSize) || 32) || 32); var fontSize = Math.max(8, Number(content && content.font_size || region && (region.font_size || region.fontSize) || 32) || 32);
+1 -1
View File
@@ -37,7 +37,7 @@
function renderPreview(region, value) { function renderPreview(region, value) {
var src = normalizePreviewValue(value !== undefined ? value : region).trim(); var src = normalizePreviewValue(value !== undefined ? value : region).trim();
if (!src) { if (!src) {
return '<div class="slide-preview-placeholder">Webpage</div>'; return '';
} }
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe>'; return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe>';
} }
+29 -15
View File
@@ -12,6 +12,7 @@ const loadMoreWrap = gallery && gallery.querySelector('[data-media-gallery-load-
const loadMoreButton = gallery && gallery.querySelector('[data-media-gallery-load-more]'); const loadMoreButton = gallery && gallery.querySelector('[data-media-gallery-load-more]');
const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]'); const selectModeButton = gallery && gallery.querySelector('[data-media-gallery-select-mode]');
const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]'); const deleteSelectedButton = gallery && gallery.querySelector('[data-media-gallery-delete-selected]');
const addMediaButton = gallery && gallery.querySelector('[data-media-library-add]');
let galleryItems = gallery ? Array.from(gallery.querySelectorAll('[data-media-gallery-column]')) : []; let galleryItems = gallery ? Array.from(gallery.querySelectorAll('[data-media-gallery-column]')) : [];
let visibleRowLimit = 4; let visibleRowLimit = 4;
let galleryOffset = gallery ? Number(gallery.dataset.mediaGalleryOffset || galleryItems.length) : 0; let galleryOffset = gallery ? Number(gallery.dataset.mediaGalleryOffset || galleryItems.length) : 0;
@@ -24,6 +25,7 @@ const uploadForm = document.querySelector('form[action="/media-library"]');
const uploadInput = document.querySelector('[data-media-library-upload]'); const uploadInput = document.querySelector('[data-media-library-upload]');
const uploadZone = document.querySelector('[data-media-library-upload-zone]'); const uploadZone = document.querySelector('[data-media-library-upload-zone]');
const uploadName = document.querySelector('[data-media-library-upload-name]'); const uploadName = document.querySelector('[data-media-library-upload-name]');
const uploadModal = document.getElementById('media-library-upload-modal');
const cropModal = document.getElementById('media-library-crop-modal'); const cropModal = document.getElementById('media-library-crop-modal');
const cropImage = cropModal && cropModal.querySelector('#media-library-crop-image'); const cropImage = cropModal && cropModal.querySelector('#media-library-crop-image');
const cropApply = cropModal && cropModal.querySelector('[data-media-library-crop-apply]'); const cropApply = cropModal && cropModal.querySelector('[data-media-library-crop-apply]');
@@ -48,22 +50,28 @@ function showUploadMessage(message) {
window.alert(message); window.alert(message);
} }
function showUploadModal() { function showModal(target) {
if (window.pulseModal && typeof window.pulseModal.show === 'function') { if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(cropModal); window.pulseModal.show(target);
} else if (window.bootstrap && window.bootstrap.Modal) { } else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(cropModal).show(); window.bootstrap.Modal.getOrCreateInstance(target).show();
} }
} }
function hideUploadModal() { function hideModal(target) {
if (window.pulseModal && typeof window.pulseModal.hide === 'function') { if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
window.pulseModal.hide(cropModal); window.pulseModal.hide(target);
} else if (window.bootstrap && window.bootstrap.Modal) { } else if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(cropModal).hide(); window.bootstrap.Modal.getOrCreateInstance(target).hide();
} }
} }
if (addMediaButton && uploadModal) {
addMediaButton.addEventListener('click', function () {
showModal(uploadModal);
});
}
function clearCropper() { function clearCropper() {
if (cropper) { if (cropper) {
cropper.destroy(); cropper.destroy();
@@ -245,7 +253,7 @@ function setUploadFile(file, shouldCrop) {
cropObjectUrl = URL.createObjectURL(file); cropObjectUrl = URL.createObjectURL(file);
cropImage.src = cropObjectUrl; cropImage.src = cropObjectUrl;
if (cropFrame) cropFrame.classList.add('is-loading'); if (cropFrame) cropFrame.classList.add('is-loading');
showUploadModal(); showModal(cropModal);
} }
} }
@@ -296,7 +304,7 @@ if (uploadZone) {
if (cropApply) { if (cropApply) {
cropApply.addEventListener('click', function () { cropApply.addEventListener('click', function () {
if (!cropper || !cropSourceFile || !uploadInput) { if (!cropper || !cropSourceFile || !uploadInput) {
hideUploadModal(); hideModal(cropModal);
return; return;
} }
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png'; const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
@@ -309,7 +317,7 @@ if (cropApply) {
const croppedFile = new File([blob], cropSourceFile.name.replace(/\.[^.]+$/, '') + extension, { type: outputType, lastModified: Date.now() }); const croppedFile = new File([blob], cropSourceFile.name.replace(/\.[^.]+$/, '') + extension, { type: outputType, lastModified: Date.now() });
clearCropper(); clearCropper();
setUploadFile(croppedFile, false); setUploadFile(croppedFile, false);
hideUploadModal(); hideModal(cropModal);
submitUpload(); submitUpload();
}, outputType, 0.92); }, outputType, 0.92);
}); });
@@ -320,7 +328,7 @@ if (cropCancel) {
clearCropper(); clearCropper();
if (uploadInput) uploadInput.value = ''; if (uploadInput) uploadInput.value = '';
if (uploadName) uploadName.textContent = 'No file selected'; if (uploadName) uploadName.textContent = 'No file selected';
hideUploadModal(); hideModal(cropModal);
}); });
} }
@@ -406,7 +414,7 @@ function updateSearchResults() {
function updateBulkDeleteActions() { function updateBulkDeleteActions() {
if (selectModeButton) { if (selectModeButton) {
selectModeButton.setAttribute('aria-pressed', selectionMode ? 'true' : 'false'); selectModeButton.setAttribute('aria-pressed', selectionMode ? 'true' : 'false');
selectModeButton.textContent = selectionMode ? 'Done' : 'Select'; selectModeButton.textContent = selectionMode ? 'Cancel' : 'Select';
} }
if (deleteSelectedButton) { if (deleteSelectedButton) {
deleteSelectedButton.hidden = !selectionMode; deleteSelectedButton.hidden = !selectionMode;
@@ -415,7 +423,10 @@ function updateBulkDeleteActions() {
galleryItems.forEach(function (item) { galleryItems.forEach(function (item) {
const id = String(item.dataset.mediaId || ''); const id = String(item.dataset.mediaId || '');
const card = item.querySelector('.media-gallery-card'); const card = item.querySelector('.media-gallery-card');
if (card) card.classList.toggle('media-gallery-card-selected', selectionMode && selectedAssetIds.has(id)); if (card) {
card.classList.toggle('media-gallery-card-selected', selectionMode && selectedAssetIds.has(id));
card.classList.toggle('media-gallery-card-selection-disabled', selectionMode && item.dataset.mediaUsed === 'true');
}
}); });
} }
@@ -487,7 +498,10 @@ if (gallery) {
if (!column || !gallery.contains(column)) return; if (!column || !gallery.contains(column)) return;
event.preventDefault(); event.preventDefault();
event.stopPropagation(); event.stopPropagation();
if (column.dataset.mediaUsed === 'true') return; if (column.dataset.mediaUsed === 'true') {
showUploadMessage('This media is in use and cannot be selected.');
return;
}
const id = String(column.dataset.mediaId || ''); const id = String(column.dataset.mediaId || '');
if (!id) return; if (!id) return;
if (selectedAssetIds.has(id)) selectedAssetIds.delete(id); if (selectedAssetIds.has(id)) selectedAssetIds.delete(id);
@@ -519,7 +533,7 @@ if (loadMoreButton) {
} }
updateSearchResults(); updateSearchResults();
function showModal() { function showPreviewModal() {
if (window.pulseModal && typeof window.pulseModal.show === 'function') { if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(modal); window.pulseModal.show(modal);
} else if (window.bootstrap && window.bootstrap.Modal) { } else if (window.bootstrap && window.bootstrap.Modal) {
@@ -550,7 +564,7 @@ if (modal && body) {
media.playsInline = true; media.playsInline = true;
} }
body.appendChild(media); body.appendChild(media);
showModal(); showPreviewModal();
}); });
modal.addEventListener('hidden.bs.modal', function () { modal.addEventListener('hidden.bs.modal', function () {
@@ -187,7 +187,7 @@ export function createSlideFormPreviewHelpers(options) {
} }
var raw = String(rawValue || ''); var raw = String(rawValue || '');
if (!raw) { if (!raw) {
return '<div class="slide-preview-placeholder">Empty text</div>'; return '';
} }
return sanitizePreviewHtml(raw); return sanitizePreviewHtml(raw);
} }
@@ -323,7 +323,7 @@ export function createSlideFormRegionHelpers(options) {
} }
if (!item || typeof item !== 'object') { if (!item || typeof item !== 'object') {
return '<div class="slide-preview-placeholder">RSS item</div>'; return '';
} }
var title = String(item.title || '').trim(); var title = String(item.title || '').trim();
@@ -336,7 +336,7 @@ export function createSlideFormRegionHelpers(options) {
summary.push('<p>' + sanitizePreviewHtml(description) + '</p>'); summary.push('<p>' + sanitizePreviewHtml(description) + '</p>');
} }
if (!summary.length) { if (!summary.length) {
return '<div class="slide-preview-placeholder">RSS item</div>'; return '';
} }
return summary.join(''); return summary.join('');
} }
@@ -1797,7 +1797,7 @@
} }
backgroundPreview.removeAttribute('src'); backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none'; backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block'; backgroundEmpty.style.display = 'flex';
}); });
} }
if (animationAdvancedModal) { if (animationAdvancedModal) {
+34 -31
View File
@@ -1,43 +1,20 @@
<div class="page-header"> <div class="page-header">
<div> <div>
<h2>Media library</h2> <h2>Media library</h2>
<p>Upload shared images and videos for use across slides and templates.</p> <p>Manage reusable images and videos for slides and templates.</p>
</div> </div>
</div> </div>
<link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" /> <link rel="stylesheet" href="/assets/vendor/cropperjs/cropper.min.css" />
{{#if (hasPermission currentUser 'media-library.create')}}
<div class="card card-outline card-primary mb-4">
<div class="card-header"><h3 class="card-title">Upload media</h3></div>
<form method="post" action="/media-library" enctype="multipart/form-data">
<div class="card-body">
<div>
<label class="slide-image-region-upload-zone media-library-upload-zone" for="media-library-upload" data-media-library-upload-zone>
<input type="file" id="media-library-upload" name="file" class="visually-hidden" accept="{{#each uploadLimits.allowedMimeTypes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}" data-media-library-upload data-image-max-bytes="{{uploadLimits.imageMaxBytes}}" data-video-max-bytes="{{uploadLimits.videoMaxBytes}}" required />
<span class="slide-image-region-upload-zone-content">
<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>
<span class="slide-image-region-upload-zone-copy">
<strong>Drop an image or video here or click to upload</strong>
<span>Images and videos enabled in Media Uploads settings</span>
<span class="slide-image-region-upload-zone-limit">Images max {{uploadLimits.imageMaxLabel}}; videos max {{uploadLimits.videoMaxLabel}}</span>
</span>
</span>
</label>
</div>
</div>
</form>
</div>
{{/if}}
<section class="media-gallery" data-media-gallery-offset="{{assets.length}}" data-media-gallery-has-more="{{#if hasMore}}true{{else}}false{{/if}}"> <section class="media-gallery" data-media-gallery-offset="{{assets.length}}" data-media-gallery-has-more="{{#if hasMore}}true{{else}}false{{/if}}">
<div class="media-gallery-toolbar"> <div class="media-gallery-toolbar">
<div> {{#if (hasPermission currentUser 'media-library.create')}}
<h3 class="h5 mb-1">Shared media</h3> <button type="button" class="btn btn-primary btn-sm text-nowrap" data-media-library-add><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add media</button>
<p class="text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p> {{/if}}
</div>
{{#if assets.length}} {{#if assets.length}}
<div class="media-gallery-controls"> <div class="media-gallery-controls">
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
<div class="media-gallery-search d-flex flex-wrap gap-2"> <div class="media-gallery-search d-flex flex-wrap gap-2">
<label class="visually-hidden" for="media-gallery-search-input">Search media</label> <label class="visually-hidden" for="media-gallery-search-input">Search media</label>
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
@@ -66,11 +43,13 @@
<option value="size-asc">Smallest first</option> <option value="size-asc">Smallest first</option>
</select> </select>
{{#if (hasPermission currentUser 'media-library.delete')}} {{#if (hasPermission currentUser 'media-library.delete')}}
<button type="button" class="btn btn-outline-secondary btn-sm" data-media-gallery-select-mode aria-pressed="false">Select</button> <button type="button" class="btn btn-secondary btn-sm" data-media-gallery-select-mode aria-pressed="false">Select</button>
<button type="button" class="btn btn-outline-danger btn-sm" data-media-gallery-delete-selected hidden disabled title="Delete selected media">Delete</button> <button type="button" class="btn btn-danger btn-sm" data-media-gallery-delete-selected hidden disabled title="Delete selected media">Delete</button>
{{/if}} {{/if}}
</div> </div>
</div> </div>
{{else}}
<p class="media-gallery-count text-body-secondary small mb-0"><span data-media-gallery-count>{{assetTotal}}</span> item{{#unless (eq assetTotal 1)}}s{{/unless}}</p>
{{/if}} {{/if}}
</div> </div>
@@ -114,7 +93,7 @@
</div> </div>
<div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results hidden>No media matches your search.</div> <div class="media-gallery-empty text-body-secondary" data-media-gallery-no-results hidden>No media matches your search.</div>
{{else}} {{else}}
<div class="media-gallery-empty text-body-secondary">No shared media yet.</div> <div class="media-gallery-empty text-body-secondary">No media yet.</div>
{{/if}} {{/if}}
</section> </section>
@@ -126,6 +105,30 @@
<div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div> <div class="modal-body media-gallery-preview-modal-body" data-media-gallery-preview-body></div>
{{/modal-shell}} {{/modal-shell}}
{{#if (hasPermission currentUser 'media-library.create')}}
{{#> modal-shell modalId="media-library-upload-modal" modalLabelId="media-library-upload-modal-label" modalDialogClass="modal-dialog-centered modal-lg"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="media-library-upload-modal-label">Add media</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<form method="post" action="/media-library" enctype="multipart/form-data">
<div class="modal-body">
<label class="slide-image-region-upload-zone media-library-upload-zone" for="media-library-upload" data-media-library-upload-zone>
<input type="file" id="media-library-upload" name="file" class="visually-hidden" accept="{{#each uploadLimits.allowedMimeTypes}}{{this}}{{#unless @last}}, {{/unless}}{{/each}}" data-media-library-upload data-image-max-bytes="{{uploadLimits.imageMaxBytes}}" data-video-max-bytes="{{uploadLimits.videoMaxBytes}}" required />
<span class="slide-image-region-upload-zone-content">
<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>
<span class="slide-image-region-upload-zone-copy">
<strong>Drop an image or video here or click to upload</strong>
<span>Images and videos enabled in Media Uploads settings</span>
<span class="slide-image-region-upload-zone-limit">Images max {{uploadLimits.imageMaxLabel}}; videos max {{uploadLimits.videoMaxLabel}}</span>
</span>
</span>
</label>
</div>
</form>
{{/modal-shell}}
{{/if}}
{{#> modal-shell modalId="media-library-crop-modal" modalLabelId="media-library-crop-modal-label" modalDialogClass="modal-dialog-centered modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}} {{#> modal-shell modalId="media-library-crop-modal" modalLabelId="media-library-crop-modal-label" modalDialogClass="modal-dialog-centered modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header"> <div class="modal-header">
<div> <div>
@@ -7,7 +7,6 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body d-grid gap-4"> <div class="modal-body d-grid gap-4">
<div class="alert alert-secondary mb-0">Changes here are saved into the region animation JSON and stay in sync with the preset dropdowns.</div>
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-lg-4"> <div class="col-12 col-lg-4">
<div class="card border-secondary-subtle"> <div class="card border-secondary-subtle">
+1 -1
View File
@@ -103,7 +103,7 @@
<div class="row g-3"> <div class="row g-3">
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<input type="file" name="background_image" id="background-image" class="d-none" accept="image/*" /> <input type="file" name="background_image" id="background-image" class="d-none" accept="image/*" />
<button type="button" class="btn btn-outline-secondary w-100" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing-background-image-path"><i class="bi bi-images me-1" aria-hidden="true"></i>Choose</button> <button type="button" class="btn btn-outline-secondary w-100" data-media-picker data-media-picker-type="image" data-media-picker-hidden="existing-background-image-path" data-media-picker-file-input="background-image"><i class="bi bi-images me-1" aria-hidden="true"></i>Choose</button>
</div> </div>
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<button type="button" class="btn btn-outline-secondary w-100" id="remove-background-image">Remove</button> <button type="button" class="btn btn-outline-secondary w-100" id="remove-background-image">Remove</button>
+19
View File
@@ -31,6 +31,25 @@ async function createTestService(options) {
return { app, server, service, temporaryDirectory }; return { app, server, service, temporaryDirectory };
} }
test('local control leaves cached player media available to the static media route', async () => {
const { app, server, temporaryDirectory } = await createTestService();
const cachedImagePath = path.join(temporaryDirectory, 'player-cache', 'remote-images', 'cached-image.png');
await fs.promises.mkdir(path.dirname(cachedImagePath), { recursive: true });
await fs.promises.writeFile(cachedImagePath, 'cached-image');
app.use('/media', express.static(temporaryDirectory));
const listener = app.listen(0);
try {
const address = listener.address();
const response = await fetch(`http://127.0.0.1:${address.port}/media/player-cache/remote-images/cached-image.png`);
assert.equal(response.status, 200);
assert.equal(await response.text(), 'cached-image');
} finally {
await new Promise((resolve) => listener.close(resolve));
server.close();
await fs.promises.rm(temporaryDirectory, { recursive: true, force: true });
}
});
test('local control authenticates only cached eligible users and scopes commands to local runtime', async () => { test('local control authenticates only cached eligible users and scopes commands to local runtime', async () => {
const { app, service, temporaryDirectory } = await createTestService(); const { app, service, temporaryDirectory } = await createTestService();
const password = hashPassword('CorrectHorseBatteryStaple!'); const password = hashPassword('CorrectHorseBatteryStaple!');
+44
View File
@@ -62,4 +62,48 @@ test('time/date region renders placeholder tokens on the player side', () => {
); );
assert.match(markup, /<p>\d{2}:\d{2}<\/p>/); assert.match(markup, /<p>\d{2}:\d{2}<\/p>/);
});
test('time/date lowercase day period renders in lowercase', () => {
const module = loadTimeDateModule();
const markup = module.renderRegion(
{
pixelWidth: 320,
pixelHeight: 180,
canvasScale: 1,
baseStyle: 'position:absolute;'
},
{
value: '{{a}}',
timezone: 'UTC'
}
);
assert.match(markup, /<p>(am|pm)<\/p>/);
});
test('time/date editor popup documents text and math transforms', () => {
const editorSource = fs.readFileSync(require.resolve('../src/web/public/js/regions/type/time-date.js'), 'utf8');
assert.match(editorSource, /renderTextTransforms\(\)/);
assert.match(editorSource, /renderMathTransforms\(\)/);
});
test('time/date region stays blank when its format is blank', () => {
const module = loadTimeDateModule();
const markup = module.renderRegion(
{
pixelWidth: 320,
pixelHeight: 180,
canvasScale: 1,
baseStyle: 'position:absolute;'
},
{
value: '',
timezone: 'UTC'
}
);
assert.match(markup, /data-time-date-format=""/);
assert.doesNotMatch(markup, /<p>\d{2}:\d{2}<\/p>/);
}); });
+3
View File
@@ -155,6 +155,9 @@ test('template create page renders the background remove control', () => {
assert.match(html, /id="remove-background-image"/); assert.match(html, /id="remove-background-image"/);
assert.match(html, /name="remove_background_image"/); assert.match(html, /name="remove_background_image"/);
assert.match(html, /data-media-picker-hidden="existing-background-image-path"/);
assert.match(html, /data-media-picker-file-input="background-image"/);
assert.match(html, /id="background-image"/);
assert.match(html, /placeholder="type_1"/); assert.match(html, /placeholder="type_1"/);
assert.match(html, /Save and Close/); assert.match(html, /Save and Close/);
assert.match(html, /Save and New/); assert.match(html, /Save and New/);