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

+10
View File
@@ -2,6 +2,16 @@
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.20.6 - 2026-10-26
### Added
- Image-only slide previews from thumbnails on the Slides list and playlist add/edit items, using retained full-size captures without loading slide assets and falling back to existing small thumbnails until refreshed.
### Fixed
- Slide preview popups refresh the existing window on repeat opens; portrait previews shrink proportionally to fit and remain centered without nested scrolling or overflow.
## 2.20.5 - 2026-10-25 ## 2.20.5 - 2026-10-25
### Fixed ### Fixed
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage-player", "name": "pulse-signage-player",
"version": "2.20.5", "version": "2.20.6",
"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.20.5", "version": "2.20.6",
"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.20.5", "version": "2.20.6",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "2.20.5", "version": "2.20.6",
"dependencies": { "dependencies": {
"@better-auth/passkey": "^1.7.13", "@better-auth/passkey": "^1.7.13",
"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.20.5", "version": "2.20.6",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"engines": { "engines": {
+1
View File
@@ -178,6 +178,7 @@ function registerActionHelpers(Handlebars) {
function registerPartials(Handlebars, viewsRoot) { function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('view-as-banner', fs.readFileSync(path.join(viewsRoot, 'shared', 'view-as-banner.hbs'), 'utf8')); 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('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('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('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')); 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 = ?', 'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
[normalizedPath] [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); 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 thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, '')); const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
const fullSizePath = filePath.replace(/\.png$/i, '.full.png'); 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 thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
const thumbnailPath = thumbnailRelativePath; const thumbnailPath = thumbnailRelativePath;
@@ -527,7 +528,7 @@ async function captureSlideThumbnail(options) {
throw new Error('Popup preview canvas has no screenshot bounds.'); throw new Error('Popup preview canvas has no screenshot bounds.');
} }
await page.screenshot({ await page.screenshot({
path: fullSizePath, path: fullSizeTempPath,
clip: { clip: {
x: Math.max(0, canvasBounds.x), x: Math.max(0, canvasBounds.x),
y: Math.max(0, canvasBounds.y), y: Math.max(0, canvasBounds.y),
@@ -546,7 +547,7 @@ async function captureSlideThumbnail(options) {
}); });
} }
await sharp(fullSizePath) await sharp(fullSizeTempPath)
.resize({ .resize({
width: THUMBNAIL_MAX_SIZE.width, width: THUMBNAIL_MAX_SIZE.width,
height: THUMBNAIL_MAX_SIZE.height, height: THUMBNAIL_MAX_SIZE.height,
@@ -558,11 +559,7 @@ async function captureSlideThumbnail(options) {
await fs.promises.rm(filePath, { force: true }); await fs.promises.rm(filePath, { force: true });
await fs.promises.rename(thumbnailTempPath, filePath); await fs.promises.rename(thumbnailTempPath, filePath);
await fs.promises.unlink(fullSizePath).catch(function (error) { await fs.promises.rename(fullSizeTempPath, fullSizePath);
if (!error || error.code !== 'ENOENT') {
throw error;
}
});
await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]); await pool.query('UPDATE c_slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
return { return {
+2 -1
View File
@@ -370,9 +370,10 @@ function createUploadSyncService(options) {
async function countUploadReferences(pool, uploadPath) { async function countUploadReferences(pool, uploadPath) {
const contentReferenceCount = await countContentReferenceOwners(pool, 'media', 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( const [thumbnailRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?', 'SELECT COUNT(*) AS ref_count FROM c_slides WHERE thumbnail_path = ?',
[uploadPath] [thumbnailPath]
); );
const [templateRows] = await pool.query( const [templateRows] = await pool.query(
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?', '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; padding: 0.875rem;
} }
.slide-editor-shell .slide-preview-scroll { .slide-editor-shell .slide-preview-stage {
max-height: calc(100vh - 8rem); max-height: max(12rem, calc(100vh - 22rem));
overflow: auto;
} }
.slide-preview-header h3 { .slide-preview-header h3 {
@@ -4658,10 +4657,15 @@ table.table > :not(caption) > * > * {
width: 100%; width: 100%;
height: auto; height: auto;
aspect-ratio: var(--slide-preview-aspect-ratio, 16 / 9); aspect-ratio: var(--slide-preview-aspect-ratio, 16 / 9);
object-fit: contain;
border: 0; border: 0;
background: var(--bs-body-bg); background: var(--bs-body-bg);
} }
.slide-list-preview-frame[hidden] {
display: none;
}
#slide-preview-modal .modal-content { #slide-preview-modal .modal-content {
overflow: hidden; overflow: hidden;
} }
@@ -899,7 +899,7 @@
return '' + return '' +
'<td data-label="Slide">' + '<td data-label="Slide">' +
'<div class="playlist-slide-cell">' + '<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 class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
'</div>' + '</div>' +
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' + '<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); thumb.style.setProperty('--playlist-slide-thumb-aspect-ratio', canvasWidth + ' / ' + canvasHeight);
} }
if (thumb) { 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 thumb.innerHTML = thumbnailPath
? '<img class="playlist-slide-thumb-image" src="' + escapeHtmlAttribute(thumbnailPath) + '" alt="" loading="lazy" data-playlist-slide-thumb-image />' ? '<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>'; : '<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()) { if (!getSelectedTemplate()) {
return; 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 popupWidth = 1280;
var popupHeight = 900; var popupHeight = 900;
var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2)); 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); 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() { function renderPreview() {
var template = getSelectedTemplate(); var template = getSelectedTemplate();
if (openPreviewPopupButton) openPreviewPopupButton.disabled = !template; if (openPreviewPopupButton) openPreviewPopupButton.disabled = !template;
@@ -787,6 +815,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
slidePreviewOverlay.style.height = ''; slidePreviewOverlay.style.height = '';
slidePreviewOverlay.style.transform = ''; slidePreviewOverlay.style.transform = '';
slidePreviewStage.style.aspectRatio = '16 / 9'; slidePreviewStage.style.aspectRatio = '16 / 9';
sizePreviewStage(16, 9);
applyBackdropStyle(slidePreviewStage, '#111111'); applyBackdropStyle(slidePreviewStage, '#111111');
return; return;
} }
@@ -798,6 +827,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
currentPreviewCanvasHeight = canvasHeight; currentPreviewCanvasHeight = canvasHeight;
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
slidePreviewStage.style.setProperty('--slide-layout-ratio', String(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); applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient);
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight; slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) { 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> <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}} {{#> 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 class="modal-header">
<div> <div>
@@ -11,13 +11,13 @@
</td> </td>
<td data-label="Slide"> <td data-label="Slide">
<div class="playlist-slide-cell"> <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}} {{#if slide.thumbnail_path}}
<img class="playlist-slide-thumb-image" src="{{slide.thumbnail_path}}" alt="" loading="lazy" data-playlist-slide-thumb-image /> <img class="playlist-slide-thumb-image" src="{{slide.thumbnail_path}}" alt="" loading="lazy" data-playlist-slide-thumb-image />
{{else}} {{else}}
<span class="playlist-slide-thumb-placeholder" data-playlist-slide-thumb-placeholder><i class="bi bi-image" aria-hidden="true"></i></span> <span class="playlist-slide-thumb-placeholder" data-playlist-slide-thumb-placeholder><i class="bi bi-image" aria-hidden="true"></i></span>
{{/if}} {{/if}}
</div> </button>
<div class="playlist-slide-cell-content"> <div class="playlist-slide-cell-content">
<span class="playlist-slide-title" data-playlist-slide-title>{{slide.title}}</span> <span class="playlist-slide-title" data-playlist-slide-title>{{slide.title}}</span>
</div> </div>
+2 -55
View File
@@ -34,7 +34,7 @@
{{#each slides}} {{#each slides}}
<tr data-table-search-row data-search-text="{{title}} {{template_name}}"> <tr data-table-search-row data-search-text="{{title}} {{template_name}}">
<td data-label="Thumbnail"> <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}} {{#if thumbnail_path}}
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" /> <img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
{{else}} {{else}}
@@ -79,62 +79,9 @@
{{> table-pagination pagination=pagination basePath="/slides" alwaysShow=true}} {{> table-pagination pagination=pagination basePath="/slides" alwaysShow=true}}
</div> </div>
{{#> modal-shell modalId="slide-preview-modal" modalLabelId="slide-preview-modal-label" modalDialogClass="modal-xl modal-dialog-centered"}} {{> slide-preview-modal}}
<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}}
<script> <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') { if (typeof window.attachSlideThumbFallbacks === 'function') {
window.attachSlideThumbFallbacks(document); window.attachSlideThumbFallbacks(document);
} }
+13
View File
@@ -193,6 +193,19 @@ test('media library counts slide, template background, and region default refere
assert.match(queries[2].sql, /thumbnail_path/); assert.match(queries[2].sql, /thumbnail_path/);
}); });
test('full-size slide previews share the small thumbnail reference', async () => {
const pool = {
async query(sql, params) {
if (sql.includes('thumbnail_path')) {
assert.deepEqual(params, ['/media/thumbnails/slides/slide-7.png']);
return [[{ ref_count: 1 }]];
}
return [[{ owner_count: 0, ref_count: 0 }]];
}
};
assert.equal(await countMediaAssetReferences(pool, '/media/thumbnails/slides/slide-7.full.png'), 1);
});
test('media library upload and delete routes audit successful asset changes', async () => { test('media library upload and delete routes audit successful asset changes', async () => {
const originalCreateVariants = mediaLibrary.createImageVariants; const originalCreateVariants = mediaLibrary.createImageVariants;
const originalRegisterAsset = mediaLibrary.registerMediaAsset; const originalRegisterAsset = mediaLibrary.registerMediaAsset;
+36
View File
@@ -5,6 +5,42 @@ require('../src/common');
const { buildPlaylistFormViewModel } = require('../src/web/routes/signage/playlists/form-view-model'); const { buildPlaylistFormViewModel } = require('../src/web/routes/signage/playlists/form-view-model');
test('playlist add and edit forms share the saved-image preview and accessible thumbnail buttons', () => {
const { renderView } = require('../src/web/view');
for (const isEdit of [false, true]) {
const html = renderView('playlists/form', {
isEdit,
playlist: { id: 3, name: 'Preview test' },
selectableSlides: [],
playlistSlideTemplate: { scheduleRules: [] },
playlistSlides: [{ slide_id: 124, title: 'Landscape', thumbnail_path: '/media/thumbnails/slides/slide-124.png', canvas_width: 1920, canvas_height: 1080, scheduleRules: [] }]
});
assert.equal((html.match(/id="slide-preview-modal"/g) || []).length, 1);
assert.match(html, /<button[^>]*data-slide-preview-url="\/media\/thumbnails\/slides\/slide-124.png"[^>]*data-canvas-width="1920"[^>]*aria-label="Preview slide: Landscape"/);
assert.match(html, /data-slide-preview-image/);
assert.doesNotMatch(html, /data-slide-preview-frame/);
}
});
test('new playlist rows receive preview URLs, titles and canvas dimensions', () => {
const fs = require('node:fs');
const vm = require('node:vm');
const source = fs.readFileSync(require.resolve('../src/web/public/js/playlists/playlist-schedule'), 'utf8');
const start = source.indexOf(' function populatePlaylistSlideRow(');
const end = source.indexOf(' function createRow(', start);
const sandbox = { escapeHtmlAttribute: value => value };
vm.runInNewContext(source.slice(start, end), sandbox);
const attrs = {};
const thumb = { setAttribute(name, value) { attrs[name] = value; }, style: { setProperty() {} } };
const row = { setAttribute() {}, querySelector(selector) { return selector === '[data-playlist-slide-thumb]' ? thumb : null; } };
sandbox.populatePlaylistSlideRow(row, { slide_id: 125, title: 'Portrait', thumbnail_path: '/media/thumbnails/slides/slide-125.png', canvas_width: 1080, canvas_height: 1920, summary: 'Always visible' }, 'new-1');
assert.equal(attrs['data-slide-preview-url'], '/media/thumbnails/slides/slide-125.png');
assert.equal(attrs['data-slide-preview-title'], 'Portrait');
assert.equal(attrs['data-canvas-width'], '1080');
assert.equal(attrs['data-canvas-height'], '1920');
assert.equal(attrs['aria-label'], 'Preview slide: Portrait');
});
test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => { test('playlist schedule summaries show up to two short rules on separate lines and count longer summaries', () => {
const fs = require('node:fs'); const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
+16
View File
@@ -111,6 +111,14 @@ test('popup preview falls back to iframe sizing rules', () => {
assert.ok(popupPreviewSource.includes('#popup-preview-canvas .slide-preview-html-region iframe')); assert.ok(popupPreviewSource.includes('#popup-preview-canvas .slide-preview-html-region iframe'));
}); });
test('slide preview shrinks proportionally without adding a nested scrollbar', () => {
const stylesheet = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(stylesheet, /\.slide-editor-shell \.slide-preview-stage\s*\{[^}]*max-height:\s*max\(12rem,\s*calc\(100vh\s*-\s*22rem\)\);/s);
assert.doesNotMatch(stylesheet, /\.slide-editor-shell \.slide-preview-scroll\s*\{[^}]*overflow:\s*auto;/s);
assert.match(slideFormSource, /function sizePreviewStage\(canvasWidth, canvasHeight\)[\s\S]*?stageContainer\.clientWidth - parseFloat\(containerStyle\.paddingLeft\) - parseFloat\(containerStyle\.paddingRight\)[\s\S]*?Math\.min\(availableWidth, maxHeight \* canvasWidth \/ canvasHeight\)[\s\S]*?marginInline = 'auto'/);
});
test('browser preview helpers normalize object-shaped HTML and webpage values', () => { test('browser preview helpers normalize object-shaped HTML and webpage values', () => {
assert.ok(slideHtmlRegionSource.includes('normalizePreviewValue')); assert.ok(slideHtmlRegionSource.includes('normalizePreviewValue'));
assert.ok(slideWebpageRegionSource.includes('normalizePreviewValue')); assert.ok(slideWebpageRegionSource.includes('normalizePreviewValue'));
@@ -182,4 +190,12 @@ test('slide template chooser searches and previews templates while preserving th
test('popup preview is disabled and guarded until a template is selected', () => { test('popup preview is disabled and guarded until a template is selected', () => {
assert.match(slideFormSource, /if \(openPreviewPopupButton\) openPreviewPopupButton\.disabled = !template;/); assert.match(slideFormSource, /if \(openPreviewPopupButton\) openPreviewPopupButton\.disabled = !template;/);
assert.match(slideFormSource, /function openPreviewPopup\(\) \{\s*if \(!getSelectedTemplate\(\)\)\s*\{\s*return;/); assert.match(slideFormSource, /function openPreviewPopup\(\) \{\s*if \(!getSelectedTemplate\(\)\)\s*\{\s*return;/);
});
test('reopening the slide popup updates the existing window without navigating through about:blank', () => {
const openPreviewPopup = slideFormSource.match(/function openPreviewPopup\(\) \{([\s\S]*?)\n function syncSlideEditorSidebar\(\)/);
assert.ok(openPreviewPopup);
assert.match(openPreviewPopup[1], /if \(previewPopupWindow && !previewPopupWindow\.closed\)[\s\S]*?location\.hash = buildPreviewPopupHash\(\);[\s\S]*?previewPopupWindow\.focus\(\);\s*return;/);
assert.ok(openPreviewPopup[1].indexOf('if (previewPopupWindow && !previewPopupWindow.closed)') < openPreviewPopup[1].indexOf("window.open('about:blank'"));
}); });
+105
View File
@@ -35,6 +35,111 @@ function loadEditorTimeDateModule() {
return modules.get('time-date'); return modules.get('time-date');
} }
test('list preview loads a saved image with legacy fallback and clears it on close', () => {
const source = fs.readFileSync(require.resolve('../src/web/views/signage/slides/list.hbs'), 'utf8');
assert.doesNotMatch(source, /<iframe|\/api\/internal\/slide-thumbnails/);
assert.match(source, /{{> slide-preview-modal}}/);
const modalSource = fs.readFileSync(require.resolve('../src/web/views/shared/slide-preview-modal.hbs'), 'utf8');
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
assert.match(css, /\.slide-list-preview-frame\[hidden\]\s*\{\s*display: none;/);
const handlers = {};
const image = {
addEventListener(name, handler) { handlers['image:' + name] = handler; },
getAttribute(name) { return this[name]; },
removeAttribute(name) { delete this[name]; }
};
const empty = {};
const title = {};
const modal = {
querySelector(selector) {
return selector === '[data-slide-preview-image]' ? image : selector === '[data-slide-preview-empty]' ? empty : title;
},
addEventListener(name, handler) { handlers[name] = handler; },
style: { setProperty() {} }
};
vm.runInNewContext(modalSource.match(/<script>([\s\S]*?)<\/script>/)[1], {
document: { getElementById() { return modal; } },
window: { innerWidth: 1440, innerHeight: 900, addEventListener() {} }
});
function open(url) {
handlers['show.bs.modal']({ relatedTarget: { getAttribute(name) {
return { 'data-slide-preview-url': url, 'data-slide-preview-title': 'Saved slide', 'data-canvas-width': '1080', 'data-canvas-height': '1920' }[name];
} } });
}
open('/media/thumbnails/slides/slide-7.png');
assert.equal(image.src, '/media/thumbnails/slides/slide-7.full.png');
assert.equal(image.hidden, false);
assert.equal(title.textContent, 'Slide preview: Saved slide');
handlers['image:error']();
assert.equal(image.src, '/media/thumbnails/slides/slide-7.png');
handlers['image:error']();
assert.equal(image.hidden, true);
assert.equal(empty.hidden, false);
open('/media/thumbnails/slides/slide-8.png');
handlers['hidden.bs.modal']();
assert.equal(image.src, undefined);
assert.equal(image.hidden, true);
open('');
assert.equal(image.src, undefined);
assert.equal(empty.hidden, false);
});
test('thumbnail capture retains a full-size preview alongside the small thumbnail', async () => {
const path = require('node:path');
const os = require('node:os');
const sharp = require('sharp');
const modulePath = require.resolve('../src/web/lib/media/slide-thumbnails');
const localRequire = require('node:module').createRequire(modulePath);
const mediaDir = await fs.promises.mkdtemp(path.join(os.tmpdir(), 'pulse-preview-'));
const page = {
async setViewport() {},
async setExtraHTTPHeaders() {},
async goto() { return { status() { return 200; } }; },
async waitForFunction() {},
async evaluate() {},
async $() { return { async boundingBox() { return { x: 0, y: 0, width: 1080, height: 1920 }; } }; },
async screenshot(options) {
await sharp({ create: { width: options.clip.width, height: options.clip.height, channels: 3, background: '#123456' } }).png().toFile(options.path);
},
async close() {}
};
const sandbox = {
module: { exports: {} },
process: { env: {} },
setTimeout(callback) { callback(); },
require(name) {
if (name === 'fs') return { ...fs, existsSync(candidate) { return candidate === '/usr/bin/chromium' || fs.existsSync(candidate); } };
if (name === 'puppeteer-core') return { async launch() { return { async newPage() { return page; }, async close() {} }; } };
return localRequire(name);
}
};
sandbox.require.resolve = localRequire.resolve;
vm.runInNewContext(fs.readFileSync(modulePath, 'utf8'), sandbox);
const queries = [];
try {
const options = {
mediaDir,
slideId: 7,
baseUrl: 'http://localhost:8080',
pool: { async query(sql, params) { queries.push({ sql, params }); } },
common: { async fetchSlideById() { return { id: 7, template: { canvas_size_width: 1080, canvas_size_height: 1920 } }; } }
};
for (let attempt = 0; attempt < 2; attempt += 1) {
const result = await sandbox.module.exports.captureSlideThumbnail(options);
const large = await sharp(result.fullSizePath).metadata();
const small = await sharp(result.filePath).metadata();
assert.equal(large.width, 1080);
assert.equal(large.height, 1920);
assert.ok(small.width <= 480 && small.height <= 270);
assert.equal(result.thumbnailPath, '/media/thumbnails/slides/slide-7.png');
assert.equal(fs.existsSync(result.fullSizePath.replace('.full.png', '.full.tmp.png')), false);
}
assert.equal(queries.length, 2);
} finally {
await fs.promises.rm(mediaDir, { recursive: true, force: true });
}
});
test('preview sanitizers preserve entity-quoted font families without double escaping', () => { test('preview sanitizers preserve entity-quoted font families without double escaping', () => {
const entities = { apos: "'", quot: '"', amp: '&', lt: '<', gt: '>' }; const entities = { apos: "'", quot: '"', amp: '&', lt: '<', gt: '>' };
const sandbox = { const sandbox = {