diff --git a/CHANGELOG.md b/CHANGELOG.md index 580f980..c1f1571 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,22 @@ All notable changes to this project will be documented in this file. - No unreleased changes recorded yet. +## 1.5.10 - 2026-07-26 + +### Added + +- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view. +- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow. + +### Changed + +- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate. +- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation. + +### Fixed + +- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably. + ## 1.5.9 - 2026-07-26 ### Fixed diff --git a/package-lock.json b/package-lock.json index 2b78d9f..c41fa5e 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "1.5.4", + "version": "1.5.10", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "1.5.4", + "version": "1.5.10", "dependencies": { "@sparticuz/chromium": "^137.0.0", "bootstrap-icons": "1.11.3", diff --git a/package.json b/package.json index 402f352..c3c6b14 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.5.9", + "version": "1.5.10", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/rbac.js b/src/rbac.js index bf60088..30a426d 100644 --- a/src/rbac.js +++ b/src/rbac.js @@ -109,10 +109,20 @@ const PERMISSION_SECTIONS = [ name: 'Background tasks', sectionName: 'Settings', actions: [ - { key: 'read', name: 'Read', description: 'View background tasks and scheduled refreshes.' }, + { key: 'read', name: 'Read', description: 'View queued background tasks.' }, { key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' } ] }, + { + key: 'scheduled-tasks', + order: 101, + name: 'Scheduled tasks', + sectionName: 'Settings', + actions: [ + { key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' }, + { key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' } + ] + }, { key: 'users', order: 80, diff --git a/src/web.js b/src/web.js index f5767c7..2ee0f55 100644 --- a/src/web.js +++ b/src/web.js @@ -25,6 +25,7 @@ const rbacData = require('./web/lib/rbac-data'); const { createPlayerActionService } = require('./web/lib/player-actions'); const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check'); const { createSessionService } = require('./web/lib/session'); +const { hasAnyPermission } = require('./rbac'); const { formatDashboardDate, readArrayField, @@ -92,12 +93,15 @@ async function start() { dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000), formatDashboardDate: formatDashboardDate, notifyPlayerScreens: notifyPlayerScreens, - backgroundTaskQueue: backgroundTaskQueue + backgroundTaskQueue: backgroundTaskQueue, + hasAnyPermission: hasAnyPermission, }); const upload = webBootstrap.upload; const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide; const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate; const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload; + const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles; + const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory; const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange; const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer; const runMediaSyncTask = webBootstrap.runMediaSyncTask; @@ -286,12 +290,14 @@ async function start() { collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, + removeUnusedUploadFiles: removeUnusedUploadFiles, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, getAuditUserId: getAuditUserId, redirectAfterSave: redirectAfterSave, notifyPlayerScreens: notifyPlayerScreens, broadcastDashboardState: broadcastDashboardState, backgroundTaskQueue: backgroundTaskQueue, + hasAnyPermission: hasAnyPermission, getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage, getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage, getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage, @@ -407,6 +413,28 @@ async function start() { }); } + async function registerRecurringMaintenanceTasks() { + async function runUnusedUploadSweep() { + const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR); + if (!uploadPaths.length) { + return; + } + + await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths); + } + + backgroundTaskQueue.registerRecurringTask({ + key: 'unused-upload-sweep', + title: 'Unused upload sweep', + category: 'media-sync', + intervalMs: 24 * 60 * 60 * 1000, + metadata: { + uploadDir: UPLOADS_DIR + }, + run: runUnusedUploadSweep + }); + } + async function scheduleInitialDataSourceRefreshes() { const apiSourcesData = await common.fetchApiSourcesData(pool); const rssFeedsData = await common.fetchRssFeedsData(pool); @@ -463,6 +491,7 @@ async function start() { } await syncRecurringRefreshes(); + await registerRecurringMaintenanceTasks(); scheduleInitialDataSourceRefreshes().catch(function (error) { console.warn('Unable to schedule startup data source refreshes:', error); }); diff --git a/src/web/bootstrap.js b/src/web/bootstrap.js index 1a5229c..abe04c3 100644 --- a/src/web/bootstrap.js +++ b/src/web/bootstrap.js @@ -120,6 +120,8 @@ function createWebBootstrap(options) { const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide; const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate; const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload; + const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles; + const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory; const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange; const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer; const runMediaSyncTask = uploadSyncService.runMediaSyncTask; @@ -210,6 +212,8 @@ function createWebBootstrap(options) { collectUploadReferencesFromSlide: collectUploadReferencesFromSlide, collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate, collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, + removeUnusedUploadFiles: removeUnusedUploadFiles, + collectUploadPathsFromDirectory: collectUploadPathsFromDirectory, syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange, syncExistingUploadsToPlayer: syncExistingUploadsToPlayer, runMediaSyncTask: runMediaSyncTask, diff --git a/src/web/lib/background-task-queue.js b/src/web/lib/background-task-queue.js index f94fd87..425a2d4 100644 --- a/src/web/lib/background-task-queue.js +++ b/src/web/lib/background-task-queue.js @@ -357,6 +357,16 @@ function createBackgroundTaskQueue(options) { scheduleRecurringRun(job, job.intervalMs); } + function runRecurringTask(recurringKey) { + const normalizedKey = normalizeText(recurringKey); + if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) { + return false; + } + + triggerRecurringJob(normalizedKey); + return true; + } + function syncRecurringTaskState(task, status, errorMessage) { const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey); if (!recurringKey) { @@ -737,6 +747,7 @@ function createBackgroundTaskQueue(options) { setTaskHandler: setTaskHandler, registerRecurringTask: registerRecurringTask, removeRecurringTask: removeRecurringTask, + runRecurringTask: runRecurringTask, listTasks: listTasks, listRecurringTasks: listRecurringTasks, getTaskById: getTaskById, diff --git a/src/web/lib/pagination.js b/src/web/lib/pagination.js index 03d6c75..3473701 100644 --- a/src/web/lib/pagination.js +++ b/src/web/lib/pagination.js @@ -17,6 +17,29 @@ function buildQueryString(query) { return queryString ? `?${queryString}` : ''; } +function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) { + const pages = []; + const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages)); + let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2)); + let endPage = startPage + visiblePageCount - 1; + + if (endPage > totalPages) { + endPage = totalPages; + startPage = Math.max(1, endPage - visiblePageCount + 1); + } + + for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) { + const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber }); + pages.push({ + number: pageNumber, + active: pageNumber === currentPage, + url: buildQueryString(nextQuery) + }); + } + + return pages; +} + function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) { const normalizedPageParam = String(pageParam || 'page').trim() || 'page'; const normalizedPageSize = Math.max(1, Number(pageSize) || 10); @@ -24,16 +47,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages); const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize; const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize); - const pages = []; - - for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) { - const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber }); - pages.push({ - number: pageNumber, - active: pageNumber === safeCurrentPage, - url: buildQueryString(nextQuery) - }); - } + const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7); const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 }); const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 }); @@ -60,5 +74,6 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz module.exports = { normalizePageNumber, buildQueryString, + buildPaginationPages, buildPagination }; \ No newline at end of file diff --git a/src/web/lib/slide-thumbnails.js b/src/web/lib/slide-thumbnails.js index 75ef2fb..a0f4a5f 100644 --- a/src/web/lib/slide-thumbnails.js +++ b/src/web/lib/slide-thumbnails.js @@ -186,6 +186,42 @@ async function captureSlideThumbnail(options) { await fs.promises.mkdir(path.dirname(filePath), { recursive: true }); + async function waitForThumbnailRender(page) { + await page.waitForFunction(function () { + return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas')); + }, { timeout: 30000 }); + + await page.waitForFunction(function () { + var canvas = document.querySelector('.slide-canvas'); + if (!canvas) { + return false; + } + + var images = Array.prototype.slice.call(canvas.querySelectorAll('img')); + return images.every(function (image) { + return image.complete && typeof image.naturalWidth === 'number'; + }); + }, { timeout: 30000 }); + + await page.evaluate(async function () { + if (document.fonts && document.fonts.ready) { + try { + await document.fonts.ready; + } catch (_error) { + // Ignore font readiness failures and fall back to the rendered frame. + } + } + }); + + await page.evaluate(function () { + return new Promise(function (resolve) { + window.requestAnimationFrame(function () { + window.requestAnimationFrame(resolve); + }); + }); + }); + } + const browser = await launchBrowser(); try { const page = await browser.newPage(); @@ -198,9 +234,7 @@ async function captureSlideThumbnail(options) { })); await page.setViewport(PLAYER_VIEWPORT); await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }); - await page.waitForFunction(function () { - return Boolean(document.querySelector('.slide-canvas')); - }, { timeout: 30000 }); + await waitForThumbnailRender(page); const canvas = await page.$('.slide-canvas'); if (!canvas) { throw new Error('Player render did not produce a slide canvas.'); diff --git a/src/web/lib/upload-sync.js b/src/web/lib/upload-sync.js index cc57532..a6e77d8 100644 --- a/src/web/lib/upload-sync.js +++ b/src/web/lib/upload-sync.js @@ -15,6 +15,7 @@ function createUploadSyncService(options) { const playerSnapshotCache = options && options.playerSnapshotCache; const notifyPlayerScreens = options && options.notifyPlayerScreens; const backgroundTaskQueue = options && options.backgroundTaskQueue; + const MAX_UPLOAD_BYTES = 100 * 1024 * 1024; let playerUploadSyncMode = null; let playerUploadSyncModePromise = null; @@ -40,7 +41,12 @@ function createUploadSyncService(options) { cb(null, `${stamp}${safeExt}`); } }); - return multer({ storage }); + return multer({ + storage: storage, + limits: { + fileSize: MAX_UPLOAD_BYTES + } + }); } function normalizeUploadReference(uploadPath) { @@ -176,6 +182,51 @@ function createUploadSyncService(options) { } } + async function collectUploadPathsFromDirectory(uploadDir) { + const normalizedUploadDir = normalizeUploadRoot(uploadDir); + if (!normalizedUploadDir) { + return []; + } + + const uploadPaths = []; + + async function walkDirectory(currentDir, relativeDir) { + let entries = []; + try { + entries = await fs.promises.readdir(currentDir, { withFileTypes: true }); + } catch (error) { + if (error && error.code !== 'ENOENT') { + console.warn('Unable to read upload directory:', currentDir, error); + } + return; + } + + for (const entry of entries) { + const entryName = String(entry && entry.name || '').trim(); + if (!entryName || entryName === '.' || entryName === '..') { + continue; + } + + const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName; + const nextAbsolutePath = path.join(currentDir, entryName); + + if (entry.isDirectory && entry.isDirectory()) { + await walkDirectory(nextAbsolutePath, nextRelativePath); + continue; + } + + if (entry.isFile && !entry.isFile()) { + continue; + } + + uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/')); + } + } + + await walkDirectory(normalizedUploadDir, ''); + return uploadPaths; + } + async function getPlayerUploadSyncMode(localUploadDir) { if (playerUploadSyncMode) { return playerUploadSyncMode; @@ -639,6 +690,7 @@ function createUploadSyncService(options) { collectUploadReferencesFromPayload: collectUploadReferencesFromPayload, countUploadReferences: countUploadReferences, removeUnusedUploadFiles: removeUnusedUploadFiles, + collectUploadPathsFromDirectory: collectUploadPathsFromDirectory, getPlayerUploadSyncMode: getPlayerUploadSyncMode, shouldMirrorUploads: shouldMirrorUploads, queuePlayerUploadSync: queuePlayerUploadSync, diff --git a/src/web/pages/index.js b/src/web/pages/index.js index 8805843..3a4180f 100644 --- a/src/web/pages/index.js +++ b/src/web/pages/index.js @@ -33,7 +33,8 @@ module.exports = { renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')), renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')), renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')), - renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')), + renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage, + renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage, renderErrorPage: require('./error'), renderRbacPage: require(routePath('settings', 'rbac', 'list')), renderRbacAddPage: require(routePath('settings', 'rbac', 'add')), diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 794971e..bea5147 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -787,6 +787,242 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile { z-index: 0; } +.slide-preview-popup-body { + margin: 0; + width: 100%; + height: 100%; + overflow: hidden; + background: #111; + color: #fff; + font-family: Arial, sans-serif; +} + +.slide-preview-popup-stage { + position: relative; + width: 100%; + height: 100%; + overflow: hidden; + background: #111; +} + +.slide-preview-popup-canvas { + position: absolute; + top: 0; + left: 0; + transform-origin: top left; + will-change: transform; +} + +.slide-preview-popup-canvas, +.slide-preview-popup-stage { + box-sizing: border-box; +} + +.slide-image-cropper-frame { + position: relative; + width: 100%; + height: 560px; + min-height: 360px; + max-height: 70vh; + overflow: hidden; + border: 0; + border-radius: 0; + background: var(--bs-body-bg); +} + +.slide-image-cropper-frame img { + display: block; + max-width: 100%; + max-height: 65vh; +} + +.slide-image-cropper-frame > .cropper-container { + width: 100% !important; + height: 100% !important; + max-width: 100% !important; + max-height: 100% !important; + display: block; +} + +.slide-image-cropper-frame > .cropper-container img { + max-width: none !important; + max-height: none !important; +} + +.slide-image-cropper-frame .cropper-container, +.slide-image-cropper-frame .cropper-canvas, +.slide-image-cropper-frame .cropper-wrap-box, +.slide-image-cropper-frame .cropper-crop-box, +.slide-image-cropper-frame .cropper-view-box, +.slide-image-cropper-frame .cropper-face { + border-radius: 0 !important; +} + +.slide-image-cropper-frame .cropper-wrap-box, +.slide-image-cropper-frame .cropper-crop-box { + border: 0 !important; +} + +.slide-image-cropper-toolbar .btn { + white-space: nowrap; +} + +.slide-image-region-preview-box { + display: grid; + gap: 0; + justify-items: end; +} + +.slide-image-region-preview-shell { + position: relative; + display: block; + width: 100%; + max-width: 288px; + height: 176px; + cursor: pointer; +} + +.slide-image-region-preview-shell:focus-visible, +.slide-image-region-preview-shell:hover .slide-image-region-preview-remove, +.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove { + opacity: 1; + transform: translate(-50%, -50%) scale(1); +} + +.slide-image-region-preview-remove { + position: absolute; + top: 50%; + left: 50%; + display: inline-flex; + align-items: center; + justify-content: center; + width: 4rem; + height: 4rem; + border-radius: 999px; + background: rgba(33, 37, 41, 0.82); + color: #fff; + opacity: 0; + transform: translate(-50%, -50%) scale(0.92); + transition: opacity 0.15s ease, transform 0.15s ease, background-color 0.15s ease; + pointer-events: none; +} + +.slide-image-region-preview-shell:hover .slide-image-region-preview-remove, +.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove { + background: rgba(var(--bs-danger-rgb), 0.92); +} + +.slide-image-region-preview-remove i { + font-size: 1.5rem; + line-height: 1; +} + +.slide-image-region-upload-zone { + flex: 1 1 auto; + display: grid; + grid-template-columns: auto 1fr; + align-items: center; + gap: 0.9rem; + min-height: 176px; + padding: 1rem 1.1rem; + border: 1px dashed var(--bs-border-color); + border-radius: 0; + background: var(--bs-tertiary-bg); + color: var(--bs-body-color); + cursor: pointer; + transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease; +} + +.slide-image-region-upload-zone-content, +.slide-image-region-upload-zone-progress { + display: contents; +} + +.slide-image-region-upload-zone-progress { + display: none; + grid-column: 1 / -1; + gap: 0.5rem; +} + +.slide-image-region-upload-zone-progress-label { + font-weight: 600; + color: var(--bs-body-color); +} + +.slide-image-region-upload-zone-progress-bar { + width: 100%; + height: 0.85rem; +} + +.slide-image-region-upload-zone-limit { + color: var(--bs-danger); + font-size: 0.75rem; + font-weight: 600; +} + +.slide-image-region-upload-zone.is-uploading { + grid-template-columns: 1fr; + pointer-events: none; + cursor: progress; + opacity: 0.95; +} + +.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-content { + display: none; +} + +.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-progress { + display: grid; +} + +.slide-image-region-upload-zone:hover, +.slide-image-region-upload-zone:focus-within, +.slide-image-region-upload-zone.is-dragover { + border-color: var(--bs-primary); + background: var(--bs-secondary-bg); + box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.12); +} + +.slide-image-region-upload-zone-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 2.5rem; + height: 2.5rem; + font-size: 1.25rem; + color: var(--bs-primary); +} + +.slide-image-region-upload-zone-copy { + display: grid; + gap: 0.2rem; +} + +.slide-image-region-upload-zone-copy span { + color: var(--bs-secondary-color); + font-size: 0.875rem; +} + +.slide-image-region-preview { + display: block; + width: 100%; + max-width: 288px; + height: 176px; + object-fit: contain; + border-radius: 0; + background: var(--bs-tertiary-bg); + border: 0; +} + +.slide-image-region-preview-empty { + display: flex; + align-items: center; + justify-content: center; + border: 0; + color: var(--bs-secondary-color); + font-size: 0.8rem; +} + .template-designer-layout { display: grid; grid-template-columns: minmax(0, 1fr) 20rem; diff --git a/src/web/public/js/settings/background-tasks.js b/src/web/public/js/settings/background-tasks.js index ae7e233..5dcc18a 100644 --- a/src/web/public/js/settings/background-tasks.js +++ b/src/web/public/js/settings/background-tasks.js @@ -4,7 +4,7 @@ var stateNode = document.getElementById('background-tasks-state'); var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : ''; - if (pathname !== '/settings/background-tasks') { + if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') { return; } diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index ed624fa..0537077 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -58,6 +58,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo var openPreviewPopupButton = document.getElementById('open-preview-popup'); var editorInstances = new Map(); var submitting = false; + var uploadMaxBytes = 100 * 1024 * 1024; + var uploadMaxLabel = '100 MB'; var previewRenderFrame = 0; var previewPopupWindow = null; var previewPopupRenderFrame = 0; @@ -773,17 +775,308 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo }); } + function getRegionUploadZone(input) { + return input ? input.closest('[data-region-upload-zone]') : null; + } + + function getRegionUploadHiddenValue(input) { + var regionId = input && input.name ? input.name.replace(/^region_image_/, '') : ''; + var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null; + var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]'); + return hidden ? hidden.value : ''; + } + + function getRegionUploadProgressNode(zone) { + return zone ? zone.querySelector('[data-region-upload-progress]') : null; + } + + function getRegionUploadProgressBar(zone) { + return zone ? zone.querySelector('[data-region-upload-progress-bar]') : null; + } + + function getRegionUploadProgressText(zone) { + return zone ? zone.querySelector('[data-region-upload-progress-text]') : null; + } + + function setRegionUploadProgress(zone, percent, text, isIndeterminate) { + var progress = getRegionUploadProgressNode(zone); + var bar = getRegionUploadProgressBar(zone); + var progressText = getRegionUploadProgressText(zone); + var normalizedPercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0)))); + + if (!progress || !bar) { + return; + } + + if (progressText) { + progressText.textContent = text || 'Uploading...'; + } + + progress.hidden = false; + bar.style.width = (isIndeterminate ? 100 : normalizedPercent) + '%'; + bar.textContent = isIndeterminate ? 'Uploading...' : normalizedPercent + '%'; + bar.setAttribute('aria-valuenow', String(isIndeterminate ? 100 : normalizedPercent)); + bar.classList.toggle('progress-bar-striped', Boolean(isIndeterminate)); + bar.classList.toggle('progress-bar-animated', Boolean(isIndeterminate)); + } + + function setRegionUploadState(input, isUploading, percent, text, isIndeterminate) { + var zone = getRegionUploadZone(input); + var progress = getRegionUploadProgressNode(zone); + + if (!zone || !progress) { + return; + } + + zone.classList.toggle('is-uploading', Boolean(isUploading)); + zone.setAttribute('aria-busy', isUploading ? 'true' : 'false'); + input.disabled = Boolean(isUploading); + + if (isUploading) { + setRegionUploadProgress(zone, percent, text, isIndeterminate); + return; + } + + progress.hidden = true; + if (input) { + input.disabled = false; + } + } + + function isRegionImageFileWithinLimit(file) { + return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes; + } + + function rejectOversizeRegionImage(input, file) { + var zone = getRegionUploadZone(input); + var hiddenValue = getRegionUploadHiddenValue(input); + + if (typeof window.showToast === 'function') { + window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning'); + } else { + window.alert('File must be ' + uploadMaxLabel + ' or smaller.'); + } + input.value = ''; + if (input.dataset.previewUrl) { + URL.revokeObjectURL(input.dataset.previewUrl); + delete input.dataset.previewUrl; + } + renderImageRegionPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue); + if (file) { + return false; + } + + return false; + } + function setRegionImageInputFile(input, file) { if (!input || !file) { return; } + if (!isRegionImageFileWithinLimit(file)) { + rejectOversizeRegionImage(input, file); + return; + } + var dataTransfer = new DataTransfer(); dataTransfer.items.add(file); input.files = dataTransfer.files; input.dispatchEvent(new Event('change', { bubbles: true })); } + function uploadRegionImageFile(input, file) { + if (!input || !file) { + return Promise.resolve(null); + } + + if (!isRegionImageFileWithinLimit(file)) { + rejectOversizeRegionImage(input, file); + return Promise.resolve(null); + } + + var regionId = input.name.replace(/^region_image_/, ''); + var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); + var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]'); + var zone = getRegionUploadZone(input); + var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2); + var previousUploadedPath = String(input.dataset.uploadedPath || '').trim(); + var previousNeedsCleanup = String(input.dataset.uploadedNeedsCleanup || '') === '1'; + + input.dataset.uploadToken = uploadToken; + setRegionUploadState(input, true, 0, 'Uploading...', true); + + return new Promise(function (resolve, reject) { + var xhr = new XMLHttpRequest(); + var formData = new FormData(); + formData.append('file', file, file.name || 'upload'); + + xhr.open('POST', '/slides/uploads', true); + xhr.responseType = 'text'; + xhr.withCredentials = true; + xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest'); + xhr.setRequestHeader('Accept', 'application/json, text/plain, */*'); + + xhr.upload.onprogress = function (event) { + if (input.dataset.uploadToken !== uploadToken) { + return; + } + + if (!event || !event.lengthComputable || !event.total) { + setRegionUploadProgress(zone, 100, 'Uploading...', true); + return; + } + + var percent = Math.round((event.loaded / event.total) * 100); + setRegionUploadProgress(zone, percent, 'Uploading ' + percent + '%', false); + }; + + xhr.onload = function () { + if (input.dataset.uploadToken !== uploadToken) { + resolve(null); + return; + } + + var response = { + ok: xhr.status >= 200 && xhr.status < 300, + status: xhr.status, + statusText: xhr.statusText, + responseText: xhr.responseText || '' + }; + + if (!response.ok) { + var error = new Error(response.responseText || 'Unable to upload image.'); + error.status = response.status; + error.responseText = response.responseText; + reject(error); + return; + } + + var payload = {}; + try { + payload = JSON.parse(response.responseText || '{}') || {}; + } catch (_error) { + payload = {}; + } + + var uploadedPath = String(payload.path || '').trim(); + if (!uploadedPath) { + reject(new Error('Unable to upload image.')); + return; + } + + if (input.dataset.previewUrl) { + URL.revokeObjectURL(input.dataset.previewUrl); + delete input.dataset.previewUrl; + } + + if (hidden) { + hidden.value = uploadedPath; + } + input.dataset.uploadedPath = uploadedPath; + input.dataset.uploadedNeedsCleanup = '1'; + input.value = ''; + input.setCustomValidity(''); + renderImageRegionPreview(card, uploadedPath); + templateSelectorLock.markEdited(); + requestPreviewRender(); + if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) { + queueUploadCleanup([previousUploadedPath]); + } + setRegionUploadState(input, false); + resolve(payload); + }; + + xhr.onerror = function () { + if (input.dataset.uploadToken !== uploadToken) { + resolve(null); + return; + } + + reject(new Error('Unable to upload image.')); + }; + + xhr.onabort = function () { + if (input.dataset.uploadToken !== uploadToken) { + resolve(null); + return; + } + + reject(new Error('Unable to upload image.')); + }; + + xhr.onloadend = function () { + if (input.dataset.uploadToken === uploadToken) { + delete input.dataset.uploadToken; + setRegionUploadState(input, false); + } + }; + + xhr.send(formData); + }).catch(function (error) { + if (input.dataset.uploadToken === uploadToken) { + delete input.dataset.uploadToken; + setRegionUploadState(input, false); + } + throw error; + }); + } + + function getPendingUploadCleanupPaths() { + return getRegionImageInputs().map(function (input) { + if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') { + return null; + } + + return String(input.dataset.uploadedPath || '').trim(); + }).filter(Boolean); + } + + function clearPendingUploadCleanupPaths() { + getRegionImageInputs().forEach(function (input) { + if (!input) { + return; + } + + delete input.dataset.uploadedNeedsCleanup; + }); + } + + function queueUploadCleanup(uploadPaths) { + var normalizedPaths = Array.from(new Set((uploadPaths || []).map(function (value) { + return String(value || '').trim(); + }).filter(Boolean))); + + if (!normalizedPaths.length) { + return; + } + + var payload = JSON.stringify({ uploadPaths: normalizedPaths }); + var url = '/slides/uploads/cleanup'; + + if (navigator.sendBeacon) { + try { + navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' })); + return; + } catch (_error) { + // Fall back to fetch below. + } + } + + fetch(url, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + 'Accept': 'application/json' + }, + body: payload, + credentials: 'same-origin', + keepalive: true + }).catch(function () { + // Best-effort cleanup only. + }); + } + function renderImageRegionPreview(card, value) { if (!card) { return; @@ -799,6 +1092,16 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo : '
No image
'; } + function getRegionImageInputs() { + return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"]')); + } + + function hasPendingRegionImageUpload() { + return getRegionImageInputs().some(function (input) { + return Boolean(input && input.files && input.files.length); + }); + } + function getRegionBox(region, canvasWidth, canvasHeight) { return { left: Math.max(0, Math.round(Number(region.x || 0))), @@ -822,23 +1125,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo '' + '' + 'Slide preview' + - '' + + '' + '' + - '' + - '