diff --git a/CHANGELOG.md b/CHANGELOG.md index 5cb0004..16541f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ 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 ### Fixed diff --git a/build/package.player.json b/build/package.player.json index 1e3fa23..1a9cbe2 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.20.5", + "version": "2.20.6", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 38747a4..8b15b64 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.20.5", + "version": "2.20.6", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/package-lock.json b/package-lock.json index 0383684..29657cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.20.5", + "version": "2.20.6", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.20.5", + "version": "2.20.6", "dependencies": { "@better-auth/passkey": "^1.7.13", "animate.css": "^4.1.1", diff --git a/package.json b/package.json index 8226606..30f2fa1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.20.5", + "version": "2.20.6", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/web/handlebars.js b/src/web/handlebars.js index 30ce29d..0f91af6 100644 --- a/src/web/handlebars.js +++ b/src/web/handlebars.js @@ -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')); diff --git a/src/web/lib/media/library.js b/src/web/lib/media/library.js index 5820a94..581f5d6 100644 --- a/src/web/lib/media/library.js +++ b/src/web/lib/media/library.js @@ -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); } diff --git a/src/web/lib/media/slide-thumbnails.js b/src/web/lib/media/slide-thumbnails.js index c96c7ec..9a7ce9a 100644 --- a/src/web/lib/media/slide-thumbnails.js +++ b/src/web/lib/media/slide-thumbnails.js @@ -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 { diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 7a4df14..b7ca57b 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -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 = ?', diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index da415af..91241d9 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -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; } diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js index 4087c99..9340d49 100644 --- a/src/web/public/js/playlists/playlist-schedule.js +++ b/src/web/public/js/playlists/playlist-schedule.js @@ -899,7 +899,7 @@ return '' + '' + '
' + - '' + + '' + '
' + values.title + '
' + '
' + '' + @@ -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 ? '' : ''; diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 66df49f..6cf2131 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -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) { diff --git a/src/web/views/shared/slide-preview-modal.hbs b/src/web/views/shared/slide-preview-modal.hbs new file mode 100644 index 0000000..4f6d423 --- /dev/null +++ b/src/web/views/shared/slide-preview-modal.hbs @@ -0,0 +1,75 @@ +{{#> modal-shell modalId="slide-preview-modal" modalLabelId="slide-preview-modal-label" modalDialogClass="modal-xl modal-dialog-centered"}} + + +{{/modal-shell}} + + \ No newline at end of file diff --git a/src/web/views/signage/playlists/form.hbs b/src/web/views/signage/playlists/form.hbs index dee947c..118a22c 100644 --- a/src/web/views/signage/playlists/form.hbs +++ b/src/web/views/signage/playlists/form.hbs @@ -89,6 +89,8 @@ +{{> 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}}