Release 2.20.6: saved-image slide previews
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m29s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
lzstealth committed 2026-10-26 17:48:05 +00:00
1 parent 205ce42337
commit 24df245ea1
20 files changed
+318 -75

No files matched your search

+1
View File
@@ -178,6 +178,7 @@ function registerActionHelpers(Handlebars) {
function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('view-as-banner', fs.readFileSync(path.join(viewsRoot, 'shared', 'view-as-banner.hbs'), 'utf8'));
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('slide-preview-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'slide-preview-modal.hbs'), 'utf8'));
Handlebars.registerPartial('media-picker-modal', fs.readFileSync(path.join(viewsRoot, 'shared', 'media-picker-modal.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
+2 -1
View File
@@ -253,7 +253,8 @@ async function countMediaAssetReferences(pool, mediaPath) {
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[normalizedPath]
);
const [thumbnailRows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', [normalizedPath]);
const thumbnailPath = normalizedPath.startsWith('/media/thumbnails/') ? normalizedPath.replace(/\.full\.png$/i, '.png') : normalizedPath;
const [thumbnailRows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', [thumbnailPath]);
return contentReferenceCount + Number(templateRows[0] && templateRows[0].ref_count || 0) + Number(thumbnailRows[0] && thumbnailRows[0].ref_count || 0);
}
+4 -7
View File
@@ -456,6 +456,7 @@ async function captureSlideThumbnail(options) {
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
const fullSizeTempPath = filePath.replace(/\.png$/i, '.full.tmp.png');
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
const thumbnailPath = thumbnailRelativePath;
@@ -527,7 +528,7 @@ async function captureSlideThumbnail(options) {
throw new Error('Popup preview canvas has no screenshot bounds.');
}
await page.screenshot({
path: fullSizePath,
path: fullSizeTempPath,
clip: {
x: Math.max(0, canvasBounds.x),
y: Math.max(0, canvasBounds.y),
@@ -546,7 +547,7 @@ async function captureSlideThumbnail(options) {
});
}
await sharp(fullSizePath)
await sharp(fullSizeTempPath)
.resize({
width: THUMBNAIL_MAX_SIZE.width,
height: THUMBNAIL_MAX_SIZE.height,
@@ -558,11 +559,7 @@ async function captureSlideThumbnail(options) {
await fs.promises.rm(filePath, { force: true });
await fs.promises.rename(thumbnailTempPath, filePath);
await fs.promises.unlink(fullSizePath).catch(function (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
});
await fs.promises.rename(fullSizeTempPath, fullSizePath);
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
return {
+2 -1
View File
@@ -370,9 +370,10 @@ function createUploadSyncService(options) {
async function countUploadReferences(pool, uploadPath) {
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', uploadPath);
const thumbnailPath = uploadPath.startsWith('/media/thumbnails/') ? uploadPath.replace(/\.full\.png$/i, '.png') : uploadPath;
const [thumbnailRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
[uploadPath]
[thumbnailPath]
);
const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
+7 -3
View File
@@ -2477,9 +2477,8 @@ td[data-label="Slides"] {
padding: 0.875rem;
}
.slide-editor-shell .slide-preview-scroll {
max-height: calc(100vh - 8rem);
overflow: auto;
.slide-editor-shell .slide-preview-stage {
max-height: max(12rem, calc(100vh - 22rem));
}
.slide-preview-header h3 {
@@ -4658,10 +4657,15 @@ table.table > :not(caption) > * > * {
width: 100%;
height: auto;
aspect-ratio: var(--slide-preview-aspect-ratio, 16 / 9);
object-fit: contain;
border: 0;
background: var(--bs-body-bg);
}
.slide-list-preview-frame[hidden] {
display: none;
}
#slide-preview-modal .modal-content {
overflow: hidden;
}
@@ -899,7 +899,7 @@
return '' +
'<td data-label="Slide">' +
'<div class="playlist-slide-cell">' +
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
'<button type="button" class="playlist-slide-thumb slide-list-thumbnail-button" data-bs-toggle="modal" data-bs-target="#slide-preview-modal" data-slide-preview-url="' + escapeHtmlAttribute(String(values.thumbnail_path || '')) + '" data-slide-preview-title="' + escapeHtmlAttribute(String(values.title || 'Slide')) + '" data-canvas-width="' + escapeHtmlAttribute(String(values.canvas_width || '')) + '" data-canvas-height="' + escapeHtmlAttribute(String(values.canvas_height || '')) + '" aria-label="Preview slide: ' + escapeHtmlAttribute(String(values.title || 'Slide')) + '"' + thumbnailStyle + '>' + thumbnailMarkup + '</button>' +
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
'</div>' +
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
@@ -3327,6 +3327,11 @@
thumb.style.setProperty('--playlist-slide-thumb-aspect-ratio', canvasWidth + ' / ' + canvasHeight);
}
if (thumb) {
thumb.setAttribute('data-slide-preview-url', thumbnailPath);
thumb.setAttribute('data-slide-preview-title', String(values.title || 'Slide'));
thumb.setAttribute('data-canvas-width', String(canvasWidth || ''));
thumb.setAttribute('data-canvas-height', String(canvasHeight || ''));
thumb.setAttribute('aria-label', 'Preview slide: ' + String(values.title || 'Slide'));
thumb.innerHTML = thumbnailPath
? '<img class="playlist-slide-thumb-image" src="' + escapeHtmlAttribute(thumbnailPath) + '" alt="" loading="lazy" data-playlist-slide-thumb-image />'
: '<span class="playlist-slide-thumb-placeholder" data-playlist-slide-thumb-placeholder><i class="bi bi-image" aria-hidden="true"></i></span>';
+30
View File
@@ -706,6 +706,21 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
if (!getSelectedTemplate()) {
return;
}
if (previewPopupWindow && !previewPopupWindow.closed) {
try {
if (previewPopupWindow.location.pathname === '/slides/popup-preview') {
previewPopupWindow.location.hash = buildPreviewPopupHash();
} else {
previewPopupWindow.location.replace(buildPreviewPopupUrl());
}
} catch (_error) {
previewPopupWindow.location.href = buildPreviewPopupUrl();
}
previewPopupWindow.focus();
return;
}
var popupWidth = 1280;
var popupHeight = 900;
var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2));
@@ -772,6 +787,19 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar);
}
function sizePreviewStage(canvasWidth, canvasHeight) {
var stageContainer = slidePreviewStage.parentElement;
var containerStyle = window.getComputedStyle(stageContainer);
var availableWidth = stageContainer.clientWidth - parseFloat(containerStyle.paddingLeft) - parseFloat(containerStyle.paddingRight);
var maxHeight = parseFloat(window.getComputedStyle(slidePreviewStage).maxHeight);
if (!availableWidth || !Number.isFinite(maxHeight)) {
return;
}
slidePreviewStage.style.width = Math.min(availableWidth, maxHeight * canvasWidth / canvasHeight) + 'px';
slidePreviewStage.style.marginInline = 'auto';
}
function renderPreview() {
var template = getSelectedTemplate();
if (openPreviewPopupButton) openPreviewPopupButton.disabled = !template;
@@ -787,6 +815,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
slidePreviewOverlay.style.height = '';
slidePreviewOverlay.style.transform = '';
slidePreviewStage.style.aspectRatio = '16 / 9';
sizePreviewStage(16, 9);
applyBackdropStyle(slidePreviewStage, '#111111');
return;
}
@@ -798,6 +827,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
currentPreviewCanvasHeight = canvasHeight;
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
slidePreviewStage.style.setProperty('--slide-layout-ratio', String(canvasWidth / canvasHeight));
sizePreviewStage(canvasWidth, canvasHeight);
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient);
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) {
@@ -0,0 +1,75 @@
{{#> modal-shell modalId="slide-preview-modal" modalLabelId="slide-preview-modal-label" modalDialogClass="modal-xl modal-dialog-centered"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="slide-preview-modal-label">Slide preview</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<img class="slide-list-preview-frame" alt="Slide preview" data-slide-preview-image hidden />
<p class="empty m-3" data-slide-preview-empty hidden>Preview unavailable</p>
</div>
{{/modal-shell}}
<script>
(function () {
var modal = document.getElementById('slide-preview-modal');
if (!modal) {
return;
}
var image = modal.querySelector('[data-slide-preview-image]');
var empty = modal.querySelector('[data-slide-preview-empty]');
var title = modal.querySelector('#slide-preview-modal-label');
var currentAspectRatio = 16 / 9;
var thumbnailUrl = '';
image.addEventListener('error', function () {
if (thumbnailUrl && image.getAttribute('src') !== thumbnailUrl) {
image.src = thumbnailUrl;
} else {
image.hidden = true;
image.removeAttribute('src');
empty.hidden = false;
}
});
function updateModalSize() {
var maxWidth = Math.min(window.innerWidth * 0.9, 1140);
var maxHeight = window.innerHeight * 0.72;
var width = Math.min(maxWidth, maxHeight * currentAspectRatio);
modal.style.setProperty('--slide-preview-aspect-ratio', String(currentAspectRatio));
modal.style.setProperty('--slide-preview-modal-width', Math.round(width) + 'px');
}
modal.addEventListener('show.bs.modal', function (event) {
var trigger = event.relatedTarget;
if (!trigger || !image) {
return;
}
var canvasWidth = Number(trigger.getAttribute('data-canvas-width'));
var canvasHeight = Number(trigger.getAttribute('data-canvas-height'));
currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 ? canvasWidth / canvasHeight : 16 / 9;
updateModalSize();
thumbnailUrl = trigger.getAttribute('data-slide-preview-url') || '';
image.hidden = !thumbnailUrl;
empty.hidden = Boolean(thumbnailUrl);
image.alt = trigger.getAttribute('data-slide-preview-title') || 'Slide preview';
if (thumbnailUrl) {
image.src = thumbnailUrl.replace(/\.png$/i, '.full.png');
}
if (title) {
title.textContent = 'Slide preview: ' + (trigger.getAttribute('data-slide-preview-title') || '');
}
});
modal.addEventListener('hidden.bs.modal', function () {
thumbnailUrl = '';
image.hidden = true;
image.removeAttribute('src');
empty.hidden = true;
});
window.addEventListener('resize', function () {
if (modal.classList.contains('show')) {
updateModalSize();
}
});
}());
</script>
+2
View File
@@ -89,6 +89,8 @@
<script type="application/json" id="playlist-add-slide-data">{{json selectableSlides}}</script>
{{> slide-preview-modal}}
{{#> modal-shell modalId="playlist-add-slide-modal" modalLabelId="playlist-add-slide-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
@@ -11,13 +11,13 @@
</td>
<td data-label="Slide">
<div class="playlist-slide-cell">
<div class="playlist-slide-thumb" aria-hidden="true" {{#if slide.canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{slide.canvas_width}} / {{slide.canvas_height}};"{{/if}} data-playlist-slide-thumb>
<button type="button" class="playlist-slide-thumb slide-list-thumbnail-button" data-bs-toggle="modal" data-bs-target="#slide-preview-modal" data-slide-preview-url="{{slide.thumbnail_path}}" data-slide-preview-title="{{slide.title}}" data-canvas-width="{{slide.canvas_width}}" data-canvas-height="{{slide.canvas_height}}" aria-label="Preview slide: {{slide.title}}" {{#if slide.canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{slide.canvas_width}} / {{slide.canvas_height}};"{{/if}} data-playlist-slide-thumb>
{{#if slide.thumbnail_path}}
<img class="playlist-slide-thumb-image" src="{{slide.thumbnail_path}}" alt="" loading="lazy" data-playlist-slide-thumb-image />
{{else}}
<span class="playlist-slide-thumb-placeholder" data-playlist-slide-thumb-placeholder><i class="bi bi-image" aria-hidden="true"></i></span>
{{/if}}
</div>
</button>
<div class="playlist-slide-cell-content">
<span class="playlist-slide-title" data-playlist-slide-title>{{slide.title}}</span>
</div>
+2 -55
View File
@@ -34,7 +34,7 @@
{{#each slides}}
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
<td data-label="Thumbnail">
<button type="button" class="playlist-slide-thumb slide-list-thumbnail-button" data-bs-toggle="modal" data-bs-target="#slide-preview-modal" data-slide-preview-url="/api/internal/slide-thumbnails/{{id}}/popup-preview" data-slide-preview-title="{{title}}" data-canvas-width="{{canvas_width}}" data-canvas-height="{{canvas_height}}" aria-label="Preview slide: {{title}}" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
<button type="button" class="playlist-slide-thumb slide-list-thumbnail-button" data-bs-toggle="modal" data-bs-target="#slide-preview-modal" data-slide-preview-url="{{thumbnail_path}}" data-slide-preview-title="{{title}}" data-canvas-width="{{canvas_width}}" data-canvas-height="{{canvas_height}}" aria-label="Preview slide: {{title}}" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
{{#if thumbnail_path}}
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
{{else}}
@@ -79,62 +79,9 @@
{{> table-pagination pagination=pagination basePath="/slides" alwaysShow=true}}
</div>
{{#> modal-shell modalId="slide-preview-modal" modalLabelId="slide-preview-modal-label" modalDialogClass="modal-xl modal-dialog-centered"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="slide-preview-modal-label">Slide preview</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body p-0">
<iframe class="slide-list-preview-frame" title="Slide preview" data-slide-preview-frame src="about:blank"></iframe>
</div>
{{/modal-shell}}
{{> slide-preview-modal}}
<script>
(function () {
var modal = document.getElementById('slide-preview-modal');
if (!modal) {
return;
}
var frame = modal.querySelector('[data-slide-preview-frame]');
var dialog = modal.querySelector('.modal-dialog');
var title = modal.querySelector('#slide-preview-modal-label');
var currentAspectRatio = 16 / 9;
function updateModalSize() {
var maxWidth = Math.min(window.innerWidth * 0.9, 1140);
var maxHeight = window.innerHeight * 0.72;
var width = Math.min(maxWidth, maxHeight * currentAspectRatio);
modal.style.setProperty('--slide-preview-aspect-ratio', String(currentAspectRatio));
modal.style.setProperty('--slide-preview-modal-width', Math.round(width) + 'px');
}
modal.addEventListener('show.bs.modal', function (event) {
var trigger = event.relatedTarget;
if (!trigger || !frame) {
return;
}
var canvasWidth = Number(trigger.getAttribute('data-canvas-width'));
var canvasHeight = Number(trigger.getAttribute('data-canvas-height'));
currentAspectRatio = canvasWidth > 0 && canvasHeight > 0 ? canvasWidth / canvasHeight : 16 / 9;
updateModalSize();
frame.src = trigger.getAttribute('data-slide-preview-url') || 'about:blank';
if (title) {
title.textContent = 'Slide preview: ' + (trigger.getAttribute('data-slide-preview-title') || '');
}
});
modal.addEventListener('hidden.bs.modal', function () {
if (frame) {
frame.src = 'about:blank';
}
});
window.addEventListener('resize', function () {
if (modal.classList.contains('show')) {
updateModalSize();
}
});
}());
if (typeof window.attachSlideThumbFallbacks === 'function') {
window.attachSlideThumbFallbacks(document);
}