This commit is contained in:
@@ -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
|
||||
|
||||
Generated
+2
-2
@@ -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",
|
||||
|
||||
+1
-1
@@ -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": {
|
||||
|
||||
+11
-1
@@ -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,
|
||||
|
||||
+30
-1
@@ -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);
|
||||
});
|
||||
|
||||
Vendored
+4
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
+25
-10
@@ -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
|
||||
};
|
||||
@@ -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.');
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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')),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
|
||||
}
|
||||
|
||||
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
|
||||
'<meta charset="utf-8" />' +
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
||||
'<title>Slide preview</title>' +
|
||||
'<style>' +
|
||||
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
|
||||
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
|
||||
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
|
||||
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
|
||||
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
|
||||
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
|
||||
'.slide-preview-overlay{z-index:2;}' +
|
||||
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
|
||||
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
|
||||
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
|
||||
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
|
||||
'</style>' +
|
||||
'<link rel="stylesheet" href="/assets/css/theme-custom.css" />' +
|
||||
'</head>' +
|
||||
'<body>' +
|
||||
'<div id="popup-preview-stage">' +
|
||||
'<div id="popup-preview-canvas"></div>' +
|
||||
'<body class="slide-preview-popup-body">' +
|
||||
'<div class="slide-preview-popup-stage" id="popup-preview-stage">' +
|
||||
'<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>' +
|
||||
'</div>' +
|
||||
'</body>' +
|
||||
'</html>';
|
||||
@@ -1329,10 +1620,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="image/*" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>PNG, JPG, GIF, or WebP</span>' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>PNG, JPG, GIF, or WebP</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadMaxLabel + ' per file</span>' +
|
||||
'</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
@@ -1494,19 +1794,52 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
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 file = input.files && input.files.length ? input.files[0] : null;
|
||||
|
||||
if (file && !isRegionImageFileWithinLimit(file)) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
input.value = '';
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
|
||||
} else {
|
||||
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
|
||||
}
|
||||
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
if (input.files && input.files.length) {
|
||||
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
|
||||
if (file) {
|
||||
input.dataset.previewUrl = URL.createObjectURL(file);
|
||||
}
|
||||
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 + '"]');
|
||||
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
if (file) {
|
||||
uploadRegionImageFile(input, file).catch(function (error) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
input.value = '';
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(error && error.message ? error.message : 'Unable to upload image.', 'danger');
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : 'Unable to upload image.');
|
||||
}
|
||||
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
||||
requestPreviewRender();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
|
||||
@@ -1535,6 +1868,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRegionImageFileWithinLimit(file)) {
|
||||
rejectOversizeRegionImage(input, file);
|
||||
return;
|
||||
}
|
||||
|
||||
setRegionImageInputFile(input, file);
|
||||
});
|
||||
});
|
||||
@@ -1544,13 +1882,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||
|
||||
if (input && input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
||||
queueUploadCleanup([uploadPath]);
|
||||
}
|
||||
if (input) {
|
||||
input.value = '';
|
||||
delete input.dataset.uploadedPath;
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
}
|
||||
if (hidden) {
|
||||
hidden.value = '';
|
||||
@@ -1567,13 +1911,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||
|
||||
if (input && input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
||||
queueUploadCleanup([uploadPath]);
|
||||
}
|
||||
if (input) {
|
||||
input.value = '';
|
||||
delete input.dataset.uploadedPath;
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
}
|
||||
if (hidden) {
|
||||
hidden.value = '';
|
||||
@@ -1656,6 +2006,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
});
|
||||
submitting = true;
|
||||
try {
|
||||
if (hasPendingRegionImageUpload()) {
|
||||
throw new Error('Wait for the image upload to finish before saving.');
|
||||
}
|
||||
await Promise.all(saves);
|
||||
var formData = new FormData(slideForm);
|
||||
if (event.submitter && event.submitter.name) {
|
||||
@@ -1686,6 +2039,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (isCreateForm) {
|
||||
try {
|
||||
if (response.url) {
|
||||
clearPendingUploadCleanupPaths();
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
@@ -1711,6 +2065,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
|
||||
showToast(savedMessage || 'Saved slide.', 'success');
|
||||
slideForm.dataset.dirty = 'false';
|
||||
clearPendingUploadCleanupPaths();
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
@@ -1761,6 +2116,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
window.addEventListener('resize', requestPreviewRender);
|
||||
window.addEventListener('resize', requestSidebarSync);
|
||||
window.addEventListener('beforeunload', function () {
|
||||
queueUploadCleanup(getPendingUploadCleanupPaths());
|
||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||
previewPopupWindow.close();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
var currentRegionAspectRatio = NaN;
|
||||
var currentRegionAspectRatioLabel = 'Region';
|
||||
var listenersAttached = false;
|
||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||
var uploadMaxLabel = '100 MB';
|
||||
|
||||
function getRegionId(input) {
|
||||
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
|
||||
@@ -249,6 +251,19 @@
|
||||
input.files = dataTransfer.files;
|
||||
}
|
||||
|
||||
function rejectOversizeFile(input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
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 = '';
|
||||
}
|
||||
|
||||
function finalizeCropFile(file) {
|
||||
if (!currentInput) {
|
||||
return;
|
||||
@@ -357,6 +372,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(file.size || 0) > uploadMaxBytes) {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
rejectOversizeFile(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^image\//i.test(file.type || '')) {
|
||||
input.value = '';
|
||||
return;
|
||||
|
||||
@@ -8,6 +8,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
||||
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
|
||||
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
|
||||
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
|
||||
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
@@ -18,14 +19,30 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
||||
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const hasAnyPermission = deps.hasAnyPermission;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') {
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
|
||||
}
|
||||
|
||||
function requireSlideUploadPermission(req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['slides.create', 'slides.update'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
}
|
||||
|
||||
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
@@ -145,6 +162,37 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/uploads', requireSlideUploadPermission, upload.single('file'), async function (req, res, next) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
||||
}
|
||||
|
||||
res.json({
|
||||
path: '/media/uploads/' + req.file.filename,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname || ''
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/uploads/cleanup', requireSlideUploadPermission, async function (req, res, next) {
|
||||
try {
|
||||
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
|
||||
? req.body.uploadPaths
|
||||
: Array.isArray(req.body && req.body.paths)
|
||||
? req.body.paths
|
||||
: [];
|
||||
|
||||
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
const payload = await common.buildSlidePayload(pool, req, null);
|
||||
|
||||
@@ -369,7 +369,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -27,7 +27,8 @@ module.exports = {
|
||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
|
||||
renderBackgroundTasksPage: require('./settings/background-tasks-page'),
|
||||
renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage,
|
||||
renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage,
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require('./rbac/list'),
|
||||
renderRbacAddPage: require('./rbac/add'),
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const { buildPaginationPages } = require('../../lib/pagination');
|
||||
|
||||
const TASKS_PER_PAGE = 10;
|
||||
const QUEUE_PAGE_URL = '/settings/background-tasks';
|
||||
const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled';
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
@@ -55,16 +58,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState) {
|
||||
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
||||
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
|
||||
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
|
||||
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 });
|
||||
@@ -137,7 +131,7 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||
return buildQueryString(nextQuery);
|
||||
}
|
||||
|
||||
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
|
||||
function buildQueuePageViewModel(data, message, currentUser) {
|
||||
const tasks = (data && data.tasks) || [];
|
||||
const recurringTasks = (data && data.recurringTasks) || [];
|
||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||
@@ -199,19 +193,15 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
});
|
||||
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
|
||||
|
||||
return renderView('settings/background-tasks/index', {
|
||||
return renderView('settings/tasks-background/index', {
|
||||
title: 'Background tasks',
|
||||
active: 'background-tasks',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/settings/background-tasks.js'],
|
||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||
backgroundTasksMenuOpen: true,
|
||||
tasks: visibleTasksWithSources,
|
||||
recurringTasks: visibleRecurringTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||
});
|
||||
}),
|
||||
summary: summary,
|
||||
statusCards: statusCards,
|
||||
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||
@@ -224,4 +214,38 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
return task.status !== 'queued' && task.status !== 'running';
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
function buildScheduledPageViewModel(data, message, currentUser) {
|
||||
const recurringTasks = (data && data.recurringTasks) || [];
|
||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
|
||||
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
|
||||
|
||||
return renderView('settings/tasks-scheduled/index', {
|
||||
title: 'Scheduled tasks',
|
||||
active: 'background-tasks-scheduled',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/settings/background-tasks.js'],
|
||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||
backgroundTasksMenuOpen: true,
|
||||
recurringTasks: recurringTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||
});
|
||||
}),
|
||||
summary: summary,
|
||||
canManage: canManage,
|
||||
scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0),
|
||||
recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) })
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderBackgroundTasksPage: function (data, message, currentUser) {
|
||||
return buildQueuePageViewModel(data, message, currentUser);
|
||||
},
|
||||
renderBackgroundTasksScheduledPage: function (data, message, currentUser) {
|
||||
return buildScheduledPageViewModel(data, message, currentUser);
|
||||
}
|
||||
};
|
||||
@@ -17,6 +17,23 @@ function requireSettingsAccess() {
|
||||
};
|
||||
}
|
||||
|
||||
function requireScheduledSettingsAccess() {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['scheduled-tasks.read', 'scheduled-tasks.allow'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
@@ -50,7 +67,28 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/settings/background-tasks/state', requireSettingsAccess(), function (req, res) {
|
||||
app.get('/settings/background-tasks/scheduled', requireScheduledSettingsAccess(), function (req, res) {
|
||||
const tasks = backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = backgroundTaskQueue.getSummary();
|
||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
||||
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/settings/background-tasks/state', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (!hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow', 'scheduled-tasks.read', 'scheduled-tasks.allow'])) {
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
return next(error);
|
||||
}
|
||||
|
||||
return next();
|
||||
}, function (req, res) {
|
||||
const tasks = backgroundTaskQueue.listTasks();
|
||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||
const summary = backgroundTaskQueue.getSummary();
|
||||
@@ -96,4 +134,22 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
||||
});
|
||||
|
||||
app.post('/settings/background-tasks/recurring/:key/run', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) {
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
return next(error);
|
||||
}
|
||||
|
||||
return next();
|
||||
}, function (req, res) {
|
||||
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
||||
res.redirect('/settings/background-tasks/scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
||||
});
|
||||
};
|
||||
|
||||
+2
-109
@@ -33,7 +33,7 @@
|
||||
|
||||
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Task queue</h3>
|
||||
<h3 class="card-title mb-0">Task Queue</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
|
||||
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
@@ -59,7 +59,6 @@
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Source</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -120,27 +119,11 @@
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{{#if canManage}}
|
||||
{{#if (eq status 'queued')}}
|
||||
<form method="post" action="/settings/background-tasks/{{id}}/cancel">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm-message="Cancel this queued task?">Cancel</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{#if (eq status 'failed')}}
|
||||
<form method="post" action="/settings/background-tasks/{{id}}/retry">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">Retry</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="8" class="empty">No background tasks are queued right now.</td>
|
||||
<td colspan="7" class="empty">No background tasks are queued right now.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
@@ -169,94 +152,4 @@
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Scheduled refreshes</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-recurring-tools">
|
||||
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Interval</th>
|
||||
<th>Next run</th>
|
||||
<th>Last run</th>
|
||||
<th>Last result</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if recurringTasks.length}}
|
||||
{{#each recurringTasks}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
</td>
|
||||
<td data-label="Interval">{{intervalLabel}}</td>
|
||||
<td data-label="Next run">
|
||||
{{#if nextRunAt}}
|
||||
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last run">
|
||||
{{#if lastRunAt}}
|
||||
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last result">
|
||||
{{#if lastStatus}}
|
||||
<span class="badge {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
|
||||
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="6" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{#if recurringPagination.hasMultiplePages}}
|
||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="text-muted small">
|
||||
Showing {{recurringPagination.startItem}}-{{recurringPagination.endItem}} of {{recurringPagination.totalItems}} scheduled refreshes
|
||||
</div>
|
||||
<nav aria-label="Scheduled refresh pages">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {{#unless recurringPagination.hasPrevious}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if recurringPagination.hasPrevious}}/settings/background-tasks{{recurringPagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||
</li>
|
||||
{{#each recurringPagination.pages}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="page-item {{#unless recurringPagination.hasNext}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if recurringPagination.hasNext}}/settings/background-tasks{{recurringPagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -0,0 +1,87 @@
|
||||
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Scheduled tasks</h2>
|
||||
<p>Use this page to view and manage recurring tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Scheduled Tasks</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center background-tasks-recurring-tools">
|
||||
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Interval</th>
|
||||
<th>Next run</th>
|
||||
<th>Last run</th>
|
||||
<th>Last result</th>
|
||||
<th>Source</th>
|
||||
{{#if canManage}}<th>Actions</th>{{/if}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if recurringTasks.length}}
|
||||
{{#each recurringTasks}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
</td>
|
||||
<td data-label="Interval">{{intervalLabel}}</td>
|
||||
<td data-label="Next run">
|
||||
{{#if nextRunAt}}
|
||||
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last run">
|
||||
{{#if lastRunAt}}
|
||||
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last result">
|
||||
{{#if lastStatus}}
|
||||
<span class="badge text-capitalize {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
|
||||
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
{{#if ../canManage}}
|
||||
<td data-label="Actions">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form method="post" action="/settings/background-tasks/recurring/{{key}}/run">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{{/if}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="{{#if canManage}}7{{else}}6{{/if}}" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -213,7 +213,7 @@
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'users.read' 'rbac.read')}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read')}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
{{#if (hasPermission currentUser 'users.read')}}
|
||||
<li class="nav-item">
|
||||
@@ -231,12 +231,33 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
||||
<i class="nav-icon bi bi-list-task"></i>
|
||||
<p>Background tasks</p>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
|
||||
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
|
||||
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
|
||||
<i class="nav-icon bi bi-pen"></i>
|
||||
<p>
|
||||
Tasks
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
<ul class="nav nav-treeview">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
||||
<i class="nav-icon bi bi-list-ol"></i>
|
||||
<p>Task queue</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/background-tasks/scheduled">
|
||||
<i class="nav-icon bi bi-calendar-event"></i>
|
||||
<p>Scheduled tasks</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
|
||||
@@ -48,7 +48,7 @@
|
||||
</div>
|
||||
|
||||
<aside class="card card-outline card-secondary admin-form-card slide-preview-card">
|
||||
<div class="card-header d-flex align-items-center justify-content-between gap-2">
|
||||
<div class="card-header d-flex align-items-center justify-content-between">
|
||||
<div class="slide-preview-header">
|
||||
<h3>Preview</h3>
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
<div class="card-body slide-preview-scroll">
|
||||
<div class="slide-preview-stage" id="slide-preview-stage">
|
||||
<div class="slide-preview-canvas" id="slide-preview-canvas">
|
||||
<img id="slide-preview-background" class="slide-preview-background" alt="" style="display:none;" />
|
||||
<img id="slide-preview-background" class="slide-preview-background d-none" alt="" />
|
||||
<div class="slide-preview-empty" id="slide-preview-empty">Select a template to preview the slide.</div>
|
||||
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
|
||||
</div>
|
||||
@@ -76,7 +76,7 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body d-grid gap-3">
|
||||
<div class="slide-image-cropper-frame" style="width:100%;height:560px;max-height:70vh;overflow:hidden;">
|
||||
<div class="slide-image-cropper-frame">
|
||||
<img id="slide-image-cropper-image" alt="Selected image to crop" />
|
||||
</div>
|
||||
<div class="btn-toolbar flex-wrap gap-2 slide-image-cropper-toolbar" role="toolbar" aria-label="Image editing controls">
|
||||
@@ -107,177 +107,12 @@
|
||||
</div>
|
||||
{{/modal-shell}}
|
||||
|
||||
<style>
|
||||
.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: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;
|
||||
}
|
||||
</style>
|
||||
|
||||
{{#if isEdit}}
|
||||
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
|
||||
{{/if}}
|
||||
|
||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
||||
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
|
||||
<script type="module" src="/assets/js/slides/slide-form.js?v=20260725.7"></script>
|
||||
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260725.4"></script>
|
||||
<script type="module" src="/assets/js/slides/slide-form.js?v=20260726.4"></script>
|
||||
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.4"></script>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user