Release v2.14.1
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 32s

This commit is contained in:
lzstealth committed 2026-10-11 18:54:43 +01:00
1 parent 635e1a9942
commit c3299abaac
174 files changed
+4483 -1687

No files matched your search

+2 -2
View File
@@ -1,8 +1,8 @@
// Web-to-player websocket bridge and dashboard state synchronizer.
const { WebSocketServer, WebSocket } = require('ws');
const { createDashboardStateService } = require('./lib/dashboard-state');
const { createUploadSyncService } = require('./lib/media');
const { createDashboardStateService } = require('#src/web/lib/dashboard-state');
const { createUploadSyncService } = require('#src/web/lib/media/index');
const { createRequestAuthHeaders } = require('#src/request-auth');
function createWebBootstrap(options) {
+3 -3
View File
@@ -1,7 +1,7 @@
// Entry point for the web auth helper modules.
module.exports = {
createSessionService: require('./session').createSessionService,
getRequestOrigin: require('./session').getRequestOrigin,
rbacData: require('./rbac-data')
createSessionService: require('#src/web/lib/auth/session').createSessionService,
getRequestOrigin: require('#src/web/lib/auth/session').getRequestOrigin,
rbacData: require('#src/web/lib/auth/rbac-data')
};
@@ -1,4 +1,4 @@
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
const TASK = {
taskType: 'data-source-refresh'
@@ -8,8 +8,8 @@ const TASK = {
intervalMs: null
};
const { normalizeIntervalMs } = require('../queue');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
const { normalizeIntervalMs } = require('#src/web/lib/background-tasks/queue');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
function registerRecurringDataSourceRefreshes(options) {
const pool = options && options.pool;
@@ -11,7 +11,7 @@ const TASK = {
const {
collectFontLibraryDirectoryUploadPaths,
collectFontLibrarySyncOperations
} = require('../../media/font-library');
} = require('#src/web/lib/media/font-library');
function registerFontSweepTask(options) {
const backgroundTaskQueue = options && options.backgroundTaskQueue;
@@ -1,4 +1,4 @@
const { collectLocalControlUsers } = require('../../local-control-users');
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
const TASK = {
key: 'player-control-sync',
@@ -1,5 +1,5 @@
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('../../data-source-refresh');
const { normalizeIntervalMs } = require('../queue');
const { refreshApiSource, refreshRssFeed, refreshWeatherLocation } = require('#src/web/lib/data-source-refresh');
const { normalizeIntervalMs } = require('#src/web/lib/background-tasks/queue');
const TASK = {
key: 'startup-data-source-refresh',
@@ -1,18 +1,13 @@
const { collectFontLibrarySyncOperations } = require('../../media/font-library');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { fetchPlayerRegistrations, isRecentPlayerRegistration } = require('#src/data/player-registry');
const TASK = {
key: 'initial-font-sync',
category: 'fonts'
};
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
// Queue a one-off font sync for each live player on startup. The task keeps the resolved player
// metadata in the payload so retries and background workers do not need to rediscover the same URL.
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
@@ -3,15 +3,11 @@ const TASK = {
category: 'media-sync',
};
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
// Queue a one-off media sync for each live player when the web app starts up. We intentionally
// do the target selection here rather than inside the task itself so the task payload remains
// explicit and reusable for later retries.
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
const { fetchPlayerRegistrations, isRecentPlayerRegistration } = require('#src/data/player-registry');
function normalizePlayerMetadata(player) {
const playerIdentifier = String(player && player.identifier || '').trim();
+3 -8
View File
@@ -1,6 +1,7 @@
// Helpers for normalizing dashboard state and date values.
const { WebSocket } = require('ws');
const { isRecentPlayerRegistration } = require('#src/data/player-registry');
function normalizeClientName(value) {
return String(value || '').trim();
@@ -52,14 +53,8 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
});
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
// Only build launcher entries for recent registrations with a public URL. This keeps the kiosk
// dashboard list focused on active devices and avoids presenting stale players as launch targets.
function buildKioskLauncherPlayers(playerRegistrations, staleSeconds) {
return (Array.isArray(playerRegistrations) ? playerRegistrations : [])
.filter(function (player) {
+3 -3
View File
@@ -1,9 +1,9 @@
// Media helper entry point for upload sync and thumbnail capture.
module.exports = {
createUploadSyncService: require('./upload-sync').createUploadSyncService,
createUploadSyncService: require('#src/web/lib/media/upload-sync').createUploadSyncService,
captureSlideThumbnail: function captureSlideThumbnail(options) {
return require('./slide-thumbnails').captureSlideThumbnail(options);
return require('#src/web/lib/media/slide-thumbnails').captureSlideThumbnail(options);
},
fontLibrary: require('./font-library')
fontLibrary: require('#src/web/lib/media/font-library')
};
+124
View File
@@ -2,6 +2,8 @@
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const sharp = require('sharp');
const MEDIA_MIME_TYPES = {
'.gif': 'image/gif',
@@ -17,6 +19,121 @@ const MEDIA_MIME_TYPES = {
'.webm': 'video/webm'
};
const OPTIMIZABLE_IMAGE_EXTENSIONS = new Set(['.jpg', '.jpeg', '.png', '.webp']);
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
function getImageVariantPath(filePath, dimension) {
const extension = path.extname(filePath);
const baseName = path.basename(filePath, extension);
return path.join(path.dirname(filePath), baseName + '.variant-' + dimension + extension);
}
async function removeImageVariants(filePath) {
const normalizedPath = String(filePath || '').trim();
const extension = path.extname(normalizedPath).toLowerCase();
if (!normalizedPath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
return;
}
await Promise.all(IMAGE_VARIANT_DIMENSIONS.map(function (dimension) {
return fs.promises.unlink(getImageVariantPath(normalizedPath, dimension)).catch(function (error) {
if (error && error.code !== 'ENOENT') {
throw error;
}
});
}));
}
async function createImageVariants(file) {
const filePath = String(file && file.path || '').trim();
const extension = path.extname(filePath).toLowerCase();
if (!filePath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
return [];
}
const maxDimension = Math.max.apply(null, IMAGE_VARIANT_DIMENSIONS);
const originalStats = await fs.promises.stat(filePath);
const metadata = await sharp(filePath, { failOn: 'none' }).metadata();
const sourceDimension = Math.max(Number(metadata.width) || 0, Number(metadata.height) || 0);
const variants = [];
for (const dimension of IMAGE_VARIANT_DIMENSIONS) {
if (dimension > maxDimension || dimension >= sourceDimension) {
continue;
}
const transformer = sharp(filePath, { failOn: 'none' })
.rotate()
.resize({ width: dimension, height: dimension, fit: 'inside', withoutEnlargement: true });
if (extension === '.jpg' || extension === '.jpeg') {
transformer.jpeg({ quality: 82, progressive: true, mozjpeg: true });
} else if (extension === '.png') {
transformer.png({ compressionLevel: 9, adaptiveFiltering: true });
} else {
transformer.webp({ quality: 82, effort: 5 });
}
const variantBuffer = await transformer.toBuffer();
if (!variantBuffer.length || variantBuffer.length >= originalStats.size) {
continue;
}
const variantPath = getImageVariantPath(filePath, dimension);
const temporaryPath = variantPath + '.partial-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporaryPath, variantBuffer);
await fs.promises.rename(temporaryPath, variantPath);
variants.push({
width: dimension,
path: '/media/uploads/' + path.basename(variantPath),
fileSize: variantBuffer.length
});
} finally {
await fs.promises.unlink(temporaryPath).catch(function () {});
}
}
return variants;
}
async function optimizeUploadedImage(file, options) {
const filePath = String(file && file.path || '').trim();
const extension = path.extname(filePath).toLowerCase();
if (!filePath || !OPTIMIZABLE_IMAGE_EXTENSIONS.has(extension)) {
return file;
}
const requestedMaxDimension = Number(options && options.maxDimension);
const maxDimension = Number.isInteger(requestedMaxDimension) && requestedMaxDimension > 0
? requestedMaxDimension
: 4096;
const originalStats = await fs.promises.stat(filePath);
const transformer = sharp(filePath, { failOn: 'none' })
.rotate()
.resize({ width: maxDimension, height: maxDimension, fit: 'inside', withoutEnlargement: true });
if (extension === '.jpg' || extension === '.jpeg') {
transformer.jpeg({ quality: 82, progressive: true, mozjpeg: true });
} else if (extension === '.png') {
transformer.png({ compressionLevel: 9, adaptiveFiltering: true });
} else {
transformer.webp({ quality: 82, effort: 5 });
}
const optimizedBuffer = await transformer.toBuffer();
if (!optimizedBuffer.length || optimizedBuffer.length >= originalStats.size) {
return file;
}
const temporaryPath = filePath + '.optimized-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporaryPath, optimizedBuffer);
await fs.promises.rename(temporaryPath, filePath);
file.size = optimizedBuffer.length;
return file;
} finally {
await fs.promises.unlink(temporaryPath).catch(function () {});
}
}
function normalizeMediaPath(value) {
const mediaPath = String(value || '').trim();
return mediaPath.startsWith('/media/') ? mediaPath : null;
@@ -103,6 +220,9 @@ async function syncMediaAssetsFromDirectory(pool, uploadDir, userId) {
continue;
}
const originalName = String(entry.name || '').trim();
if (/\.variant-\d+\.(?:jpe?g|png|webp)$/i.test(originalName)) {
continue;
}
const mimeType = MEDIA_MIME_TYPES[path.extname(originalName).toLowerCase()];
if (!mimeType) {
continue;
@@ -163,11 +283,15 @@ async function removePendingMediaAssets(pool, uploadDir, mediaPaths) {
throw error;
}
});
await removeImageVariants(filePath);
}
}
module.exports = {
normalizeMediaPath,
createImageVariants,
removeImageVariants,
optimizeUploadedImage,
registerMediaAsset,
registerMediaAssets,
fetchMediaAssets,
@@ -156,6 +156,10 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
return '<div class="slide-preview-region slide-preview-rtmp-region" style="' + region.baseStyle + '"><div class="slide-preview-rtmp-placeholder">' + escapeHtml(label) + '</div></div>';
}
if (regionType === 'chart') {
return '<div class="slide-preview-region slide-preview-chart-region" style="' + region.baseStyle + 'position:relative;background:transparent;"><canvas data-chart-json="' + escapeHtml(JSON.stringify(regionContent || {})) + '" aria-label="' + escapeHtml(region.label || region.region_key || 'chart') + '"></canvas></div>';
}
return buildTextRegionMarkup(region, regionContent);
}
@@ -188,6 +192,7 @@ function buildThumbnailPreviewPayload(slide, options) {
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
apiSources: Array.isArray(options && options.apiSources) ? options.apiSources : [],
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
};
+74 -56
View File
@@ -1,53 +1,29 @@
// Upload sync helpers for mirroring web media to player storage.
// This service owns the upload queue and deferred player-target selection, while the shared
// player registry helper owns the repeated URL/heartbeat resolution rules used elsewhere.
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const multer = require('multer');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('./font-library');
const { fetchPlayerRegistrations, getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const {
fetchPlayerRegistrations,
getConfiguredPlayerIdentifier,
isLocalLikeBaseUrl,
isRecentPlayerRegistration,
normalizeBaseUrl
} = require('#src/data/player-registry');
function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim());
}
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function normalizePlayerRowBaseUrl(player) {
return normalizeBaseUrl(player && player.internal_base_url);
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
function createUploadSyncService(options) {
const pool = options && options.pool;
const common = options && options.common;
@@ -297,6 +273,30 @@ function createUploadSyncService(options) {
return path.join(mediaRoot, relativePath);
}
async function getUploadPathsWithVariants(uploadPath, uploadDir) {
const normalizedPath = normalizeUploadReference(uploadPath);
const sourcePath = resolveUploadFilePath(uploadDir, normalizedPath);
if (!normalizedPath || !sourcePath || !/\.(?:jpe?g|png|webp)$/i.test(normalizedPath)) {
return normalizedPath ? [normalizedPath] : [];
}
const extension = path.extname(sourcePath);
const baseName = path.basename(sourcePath, extension);
const directory = path.dirname(sourcePath);
let entries = [];
try {
entries = await fs.promises.readdir(directory, { withFileTypes: true });
} catch (_error) {
return [normalizedPath];
}
const variantPattern = new RegExp('^' + baseName.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') + '\\.variant-\\d+' + extension.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\$&') + '$', 'i');
return [normalizedPath].concat(entries
.filter(function (entry) { return entry && entry.isFile() && variantPattern.test(entry.name); })
.map(function (entry) { return normalizedPath.replace(/[^/]+$/, entry.name); })
.sort());
}
function collectUploadReferencesFromValue(value, refs) {
if (!value) {
return refs;
@@ -584,8 +584,8 @@ function createUploadSyncService(options) {
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath || !shouldMirrorUploads(localUploadDir)) {
async function removeSingleUploadFileFromPlayer(uploadPath, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
if (!uploadPath) {
return false;
}
@@ -632,6 +632,17 @@ function createUploadSyncService(options) {
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier) {
const uploadPaths = await getUploadPathsWithVariants(uploadPath, localUploadDir);
let success = true;
for (let index = 0; index < uploadPaths.length; index += 1) {
if (!await removeSingleUploadFileFromPlayer(uploadPaths[index], resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier)) {
success = false;
}
}
return success;
}
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir, preferredPlayerIdentifier, preferredPlayerInternalBaseUrl) {
if (!shouldMirrorUploads(localUploadDir)) {
return;
@@ -640,19 +651,23 @@ function createUploadSyncService(options) {
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl(preferredPlayerIdentifier, preferredPlayerInternalBaseUrl);
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) {
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
if (!success) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uniqueRefs[i],
uploadDir: localUploadDir,
playerIdentifier: preferredPlayerIdentifier,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
playerIdentifier: preferredPlayerIdentifier || null,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
} : null
});
const uploadPaths = await getUploadPathsWithVariants(uniqueRefs[i], localUploadDir);
for (let pathIndex = 0; pathIndex < uploadPaths.length; pathIndex += 1) {
const uploadPath = uploadPaths[pathIndex];
const success = await pushUploadFileToPlayer(uploadPath, localUploadDir, resolvedPlayerInternalBaseUrl, preferredPlayerIdentifier);
if (!success) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: localUploadDir,
playerIdentifier: preferredPlayerIdentifier,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl,
metadata: preferredPlayerIdentifier || preferredPlayerInternalBaseUrl ? {
playerIdentifier: preferredPlayerIdentifier || null,
playerInternalBaseUrl: preferredPlayerInternalBaseUrl || null
} : null
});
}
}
}
}
@@ -938,14 +953,17 @@ function createUploadSyncService(options) {
}).forEach(function (uploadPath) {
uploadRefs.add(uploadPath);
});
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: uploadDir,
metadata: playerMetadata
for (const uploadPath of uploadRefs) {
const uploadPaths = await getUploadPathsWithVariants(uploadPath, uploadDir);
uploadPaths.forEach(function (variantPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: variantPath,
uploadDir: uploadDir,
metadata: playerMetadata
});
});
});
}
fontLibraryOperations.forEach(function (operation) {
if (!operation || !operation.uploadPath) {
return;
+8 -33
View File
@@ -1,39 +1,14 @@
// Commands and synchronization actions forwarded from the web app to players.
// Keep the routing layer separate from the actual player device commands so the web app can
// resolve a target player URL without mixing in connection or device-specific logic.
const { createRequestAuthHeaders } = require('#src/request-auth');
const { getConfiguredPlayerIdentifier } = require('#src/data/player-registry');
function isLocalLikeBaseUrl(value) {
let host = '';
try {
host = new URL(String(value || '').trim().replace(/\/$/, '')).hostname.toLowerCase();
} catch (_error) {
return false;
}
return host === 'localhost'
|| host === '127.0.0.1'
|| host === '::1'
|| host === 'host.docker.internal'
|| host === 'player'
|| host === 'web'
|| host === 'player-bridge'
|| host.endsWith('.local')
|| host.endsWith('.internal')
|| host.endsWith('.docker.internal');
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
const {
getConfiguredPlayerIdentifier,
isLocalLikeBaseUrl,
isRecentPlayerRegistration,
normalizeBaseUrl
} = require('#src/data/player-registry');
function createPlayerActionService(options) {
const pool = options && options.pool;
+1 -1
View File
@@ -6,7 +6,7 @@ const path = require('path');
const PUBLIC_JS_ROOT = path.join(__dirname, '..', 'public', 'js');
const REGION_ROOT_DIR = path.join(PUBLIC_JS_ROOT, 'regions');
const REGION_TYPE_DIR = path.join(REGION_ROOT_DIR, 'type');
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js'];
const REGION_CORE_SCRIPTS = ['js/shared/placeholder-utils.js', 'js/shared/placeholder-chips.js', 'js/shared/placeholder-info.js', 'js/shared/time-date-placeholders.js', 'js/regions/region-utils.js', 'js/regions/region-types.js', 'js/regions/chart-data.js', 'vendor/chart.js/chart.umd.js'];
function withAssetVersion(scriptPath, assetVersion) {
if (!assetVersion) {
+1 -1
View File
@@ -58,7 +58,7 @@ module.exports = {
renderMediaLibraryPage: require(routePath('settings', 'media-library', 'list')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
renderErrorPage: require('#src/web/pages/error'),
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
renderRbacEditPage: require(routePath('settings', 'rbac', 'edit'))
-6
View File
@@ -162,12 +162,6 @@
width: 100%;
height: 100%;
object-fit: cover;
transition: transform .2s ease;
}
.media-gallery-preview:hover img,
.media-gallery-preview:hover video {
transform: scale(1.03);
}
.media-gallery-preview:focus-visible {
+138
View File
@@ -204,6 +204,87 @@
}
}
function queueUploadCleanup(input, uploadPath) {
var path = String(uploadPath || '').trim();
var cleanupUrl = String(input && input.dataset.uploadCleanupUrl || '').trim();
if (!path || !cleanupUrl) {
return;
}
var payload = JSON.stringify({ uploadPaths: [path] });
if (navigator.sendBeacon) {
try {
navigator.sendBeacon(cleanupUrl, new Blob([payload], { type: 'application/json' }));
return;
} catch (_error) {
// Fall through to fetch when sendBeacon is unavailable.
}
}
fetch(cleanupUrl, {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: payload,
credentials: 'same-origin',
keepalive: true
}).catch(function () {});
}
async function uploadSelectedFile(input, file) {
var uploadUrl = String(input.dataset.uploadUrl || '').trim();
var hidden = document.getElementById(String(input.dataset.uploadHidden || '').trim());
if (!uploadUrl || !hidden || !file) {
return;
}
var maxBytes = Number(input.dataset.imageMaxBytes || 0);
if (maxBytes > 0 && file.size > maxBytes) {
input.value = '';
window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.');
return;
}
var formData = new FormData();
formData.append('file', file, file.name || 'upload');
input.setCustomValidity('Upload is still in progress.');
try {
var response = await fetch(uploadUrl, {
method: 'POST',
body: formData,
credentials: 'same-origin',
headers: { Accept: 'application/json', 'X-Requested-With': 'XMLHttpRequest' }
});
var payload = await response.json().catch(function () { return {}; });
if (!response.ok || !payload.path) {
throw new Error(payload.error || 'Unable to upload media.');
}
var previousPath = String(input.dataset.uploadedPath || '').trim();
if (previousPath && previousPath !== payload.path) {
queueUploadCleanup(input, previousPath);
}
input.dataset.uploadedPath = payload.path;
input.dataset.uploadedNeedsCleanup = '1';
input.value = '';
input.setCustomValidity('');
hidden.value = payload.path;
hidden.dispatchEvent(new Event('change', { bubbles: true }));
updatePreview(hidden, payload.path, 'image');
} catch (error) {
input.value = '';
var currentPath = String(hidden.value || '').trim();
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
if (currentPath) {
updatePreview(hidden, currentPath, 'image');
} else if (backgroundPreview) {
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
if (backgroundEmpty) backgroundEmpty.style.display = 'flex';
}
input.setCustomValidity('');
window.PulseUpload.showMessage(error.message || 'Unable to upload media.');
} finally {
}
}
document.addEventListener('click', function (event) {
var uploadTrigger = event.target && event.target.closest ? event.target.closest('[data-media-picker-upload]') : null;
if (uploadTrigger) {
@@ -247,8 +328,46 @@
updateSelection();
});
document.addEventListener('click', function (event) {
var removeBackground = event.target && event.target.closest ? event.target.closest('#remove-background-image') : null;
if (!removeBackground) {
return;
}
var input = document.getElementById('background-image');
if (input && input.dataset.uploadedNeedsCleanup === '1') {
queueUploadCleanup(input, input.dataset.uploadedPath);
delete input.dataset.uploadedNeedsCleanup;
delete input.dataset.uploadedPath;
}
});
if (search) search.addEventListener('input', resetVisibleItemLimit);
if (sort) sort.addEventListener('change', resetVisibleItemLimit);
document.addEventListener('change', function (event) {
var input = event.target;
if (!input || input.type !== 'file' || !input.dataset) {
return;
}
var file = input.files && input.files[0];
if (input.dataset.uploadUrl) {
input.setCustomValidity('');
if (file) {
uploadSelectedFile(input, file);
}
return;
}
if (!input.dataset.uploadMaxBytes) {
return;
}
var maxBytes = Number(input.dataset.uploadMaxBytes);
if (!file || !Number.isFinite(maxBytes) || maxBytes <= 0 || file.size <= maxBytes) {
input.setCustomValidity('');
return;
}
input.value = '';
input.setCustomValidity('Image must be ' + Math.round(maxBytes / 1024 / 1024) + ' MB or smaller.');
input.reportValidity();
});
if (loadMoreButton) loadMoreButton.addEventListener('click', async function () {
loadMoreButton.disabled = true;
try {
@@ -274,12 +393,31 @@
closePicker();
return;
}
var input = getActiveFileInput();
if (input && input.dataset.uploadedNeedsCleanup === '1' && input.dataset.uploadedPath !== pendingPath) {
queueUploadCleanup(input, input.dataset.uploadedPath);
delete input.dataset.uploadedNeedsCleanup;
delete input.dataset.uploadedPath;
}
hidden.value = pendingPath;
updatePreview(hidden, pendingPath, pendingType);
hidden.dispatchEvent(new Event('change', { bubbles: true }));
closePicker();
});
if (cancelButton) cancelButton.addEventListener('click', closePicker);
document.querySelectorAll('input[type="file"][data-upload-url]').forEach(function (input) {
var form = input.form;
if (form) {
form.addEventListener('submit', function () {
delete input.dataset.uploadedNeedsCleanup;
});
}
});
window.addEventListener('beforeunload', function () {
document.querySelectorAll('input[type="file"][data-upload-url][data-uploaded-needs-cleanup="1"]').forEach(function (input) {
queueUploadCleanup(input, input.dataset.uploadedPath);
});
});
modal.addEventListener('hidden.bs.modal', function () {
activeButton = null;
currentPath = '';
+257
View File
@@ -0,0 +1,257 @@
// Normalize chart configurations and source snapshots for editor previews and player playback.
(function () {
var root = window;
var allowedTypes = ['line', 'bar', 'doughnut', 'pie'];
function getPath(value, path) {
var current = value;
var parts = String(path || '').trim().replace(/\[([0-9]+)\]/g, '.$1').split('.').filter(Boolean);
for (var index = 0; index < parts.length; index += 1) {
if (current === undefined || current === null) {
return undefined;
}
current = current[parts[index]];
}
return current;
}
function toNumber(value) {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null;
}
var normalized = String(value === undefined || value === null ? '' : value).replace(/,/g, '').trim();
if (!normalized) {
return null;
}
var parsed = Number(normalized);
return Number.isFinite(parsed) ? parsed : null;
}
function normalizeChartType(value) {
var type = String(value || 'bar').trim().toLowerCase();
return allowedTypes.indexOf(type) !== -1 ? type : 'bar';
}
function getApiItems(response, itemsPath) {
var selected = itemsPath ? getPath(response, itemsPath) : response;
if (Array.isArray(selected)) {
return selected;
}
if (Array.isArray(response)) {
return response;
}
if (response && Array.isArray(response.items)) return response.items;
if (response && Array.isArray(response.results)) return response.results;
if (response && Array.isArray(response.data)) return response.data;
return response && typeof response === 'object' ? [response] : [];
}
function normalizeRows(rows, mapping) {
var labels = [];
var values = [];
var labelPath = String(mapping && (mapping.label || mapping.label_path) || 'label').trim();
var valuePath = String(mapping && (mapping.value || mapping.value_path) || 'value').trim();
(Array.isArray(rows) ? rows : []).forEach(function (row, index) {
var label = getPath(row, labelPath);
var value = toNumber(getPath(row, valuePath));
if (value === null) {
return;
}
labels.push(label === undefined || label === null || label === '' ? String(index + 1) : String(label));
values.push(value);
});
return { labels: labels, values: values };
}
function normalizeConfig(content) {
var current = content && typeof content === 'object' ? content : {};
var mapping = current.mapping && typeof current.mapping === 'object' ? current.mapping : {};
var rawDatasets = Array.isArray(current.datasets) ? current.datasets : [];
var datasets = rawDatasets.slice(0, 12).map(function (dataset, index) {
var item = dataset && typeof dataset === 'object' ? dataset : {};
return {
label: String(item.label || 'Series ' + (index + 1)).trim() || 'Series ' + (index + 1),
value_path: String(item.value_path || item.value || '').trim(),
color: /^#[0-9a-fA-F]{3,8}$/.test(String(item.color || '').trim()) ? String(item.color).trim() : ''
};
}).filter(function (dataset) { return dataset.value_path; });
var palette = Array.isArray(current.colors)
? current.colors
: String(current.color_palette || '').split(',');
palette = palette.map(function (color) {
return String(color || '').trim();
}).filter(function (color) {
return /^#[0-9a-fA-F]{3,8}$/.test(color);
}).slice(0, 24);
var fallbackColor = /^#[0-9a-fA-F]{3,8}$/.test(String(current.color || '').trim()) ? String(current.color).trim() : '#36a269';
var chartOptions = current.chart_options && typeof current.chart_options === 'object' ? current.chart_options : {};
var sourceOptions = current.source_options && typeof current.source_options === 'object' ? current.source_options : {};
return {
type: normalizeChartType(current.chart_type || current.chartType),
source_kind: String(current.source_kind || current.sourceKind || 'manual').trim().toLowerCase() === 'api' ? 'api' : 'manual',
source_id: current.source_id === undefined || current.source_id === '' ? '' : Number(current.source_id),
items_path: String(current.items_path || '').trim(),
label_path: String(current.label_path || mapping.label || '').trim(),
value_path: String(current.value_path || mapping.value || '').trim(),
dataset_label: String(current.dataset_label || 'Value').trim() || 'Value',
color: fallbackColor,
datasets: datasets,
colors: palette.length ? palette : [fallbackColor, '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6'],
manual_rows: Array.isArray(current.manual_rows) ? current.manual_rows : [],
chart_options: {
legend: ['automatic', 'show', 'hide'].indexOf(String(chartOptions.legend || 'automatic')) !== -1 ? String(chartOptions.legend || 'automatic') : 'automatic',
begin_at_zero: chartOptions.begin_at_zero !== false,
y_axis_min: chartOptions.y_axis_min === undefined || chartOptions.y_axis_min === '' ? '' : Number(chartOptions.y_axis_min),
y_axis_max: chartOptions.y_axis_max === undefined || chartOptions.y_axis_max === '' ? '' : Number(chartOptions.y_axis_max),
line_points: ['automatic', 'show', 'hide'].indexOf(String(chartOptions.line_points || 'automatic')) !== -1 ? String(chartOptions.line_points || 'automatic') : 'automatic',
line_fill: chartOptions.line_fill === true,
bar_orientation: chartOptions.bar_orientation === 'horizontal' ? 'horizontal' : 'vertical',
value_format: ['number', 'percent', 'currency'].indexOf(String(chartOptions.value_format || 'number')) !== -1 ? String(chartOptions.value_format || 'number') : 'number',
decimal_places: Math.max(0, Math.min(4, Number(chartOptions.decimal_places) || 0)),
text_size: Math.max(8, Math.min(48, Number(chartOptions.text_size) || 12)),
grid_color: /^#[0-9a-fA-F]{6}$/.test(String(chartOptions.grid_color || '').trim()) ? String(chartOptions.grid_color).trim() : '#000000',
text_color: /^#[0-9a-fA-F]{6}$/.test(String(chartOptions.text_color || '').trim()) ? String(chartOptions.text_color).trim() : '#000000'
},
source_options: {
sort: ['none', 'reverse-original', 'value-ascending', 'value-descending'].indexOf(String(sourceOptions.sort || 'none')) !== -1 ? String(sourceOptions.sort || 'none') : 'none',
limit: Math.max(0, Math.min(500, Number(sourceOptions.limit) || 0)),
missing: ['skip', 'zero', 'gap'].indexOf(String(sourceOptions.missing || 'gap')) !== -1 ? String(sourceOptions.missing || 'gap') : 'gap'
}
};
}
function isColor(value) {
return /^#[0-9a-fA-F]{3,8}$/.test(String(value || '').trim());
}
function getSeries(config) {
if (config.type === 'line' && config.datasets.length) {
return config.datasets;
}
var firstDataset = config.datasets[0] || {};
return [{
label: config.dataset_label,
value_path: config.value_path || firstDataset.value_path || 'value',
color: config.color || firstDataset.color
}];
}
function normalizeSeries(rows, config) {
var series = getSeries(config);
var labels = [];
var values = series.map(function () { return []; });
var colors = [];
var sourceOptions = config.source_options || {};
var sourceRows = (Array.isArray(rows) ? rows : []).slice();
if (sourceOptions.sort !== 'none' && sourceOptions.sort !== 'reverse-original') {
sourceRows.sort(function (left, right) {
var firstSeries = series[0] || {};
var leftValue = toNumber(getPath(left, firstSeries.value_path));
var rightValue = toNumber(getPath(right, firstSeries.value_path));
if (leftValue === null) return 1;
if (rightValue === null) return -1;
return (leftValue - rightValue) * (sourceOptions.sort === 'value-descending' ? -1 : 1);
});
}
if (sourceOptions.sort === 'reverse-original') sourceRows.reverse();
if (sourceOptions.limit > 0) sourceRows = sourceRows.slice(0, sourceOptions.limit);
sourceRows.forEach(function (row, rowIndex) {
var rowValues = series.map(function (dataset) {
return toNumber(getPath(row, dataset.value_path));
});
if (!rowValues.some(function (value) { return value !== null; })) {
return;
}
if (sourceOptions.missing === 'skip' && rowValues.some(function (value) { return value === null; })) {
return;
}
if (sourceOptions.missing === 'zero') {
rowValues = rowValues.map(function (value) { return value === null ? 0 : value; });
}
var label = getPath(row, config.label_path || 'label');
labels.push(label === undefined || label === null || label === '' ? String(rowIndex + 1) : String(label));
rowValues.forEach(function (value, datasetIndex) {
values[datasetIndex].push(value);
});
colors.push(isColor(row && row.color) ? String(row.color).trim() : config.colors[rowIndex % config.colors.length]);
});
return { labels: labels, values: values, colors: colors };
}
function fromContent(content, sources) {
var config = normalizeConfig(content);
var rows = config.manual_rows;
if (config.source_kind === 'api') {
var source = (Array.isArray(sources) ? sources : []).find(function (entry) {
return Number(entry.id) === Number(config.source_id);
});
rows = getApiItems(source && source.responseJson, config.items_path || source && (source.items_path || source.itemsPath) || '');
}
var normalizedRows = normalizeSeries(rows, config);
var series = getSeries(config);
var linePointRadius = config.chart_options.line_points === 'show' ? 3 : config.chart_options.line_points === 'hide' ? 0 : undefined;
return {
type: config.type,
options: { chart_options: config.chart_options },
labels: normalizedRows.labels,
datasets: series.map(function (dataset, index) {
var color = dataset.color || config.colors[index % config.colors.length];
var pointColors = config.type === 'bar' || config.type === 'pie' || config.type === 'doughnut' ? normalizedRows.colors : null;
return {
label: dataset.label,
data: normalizedRows.values[index],
backgroundColor: pointColors || color,
borderColor: pointColors || color,
borderWidth: 2,
tension: 0.25,
fill: config.type === 'line' && config.chart_options.line_fill,
pointRadius: linePointRadius
};
})
};
}
function getChartOptions(chartData) {
var chartOptions = chartData.options && chartData.options.chart_options || {};
var textSize = Math.max(8, Math.min(48, Number(chartOptions.text_size) || 12));
var legendDefault = chartData.type !== 'bar' && (chartData.type !== 'line' || (chartData.datasets || []).length > 1);
var legendDisplay = chartOptions.legend === 'show' ? true : chartOptions.legend === 'hide' ? false : legendDefault;
var scales = {};
var ticks = { color: chartOptions.text_color || '#000000', font: { size: textSize } };
if (chartData.type !== 'pie' && chartData.type !== 'doughnut') {
var valueScale = { beginAtZero: chartOptions.begin_at_zero !== false, grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
if (chartOptions.y_axis_min !== '') valueScale.min = chartOptions.y_axis_min;
if (chartOptions.y_axis_max !== '') valueScale.max = chartOptions.y_axis_max;
scales[chartData.type === 'bar' && chartOptions.bar_orientation === 'horizontal' ? 'x' : 'y'] = valueScale;
if (chartData.type === 'bar' && chartOptions.bar_orientation === 'horizontal') scales.y = { beginAtZero: true, grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
if (!scales.x) scales.x = { grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
if (!scales.y) scales.y = { grid: { color: chartOptions.grid_color || '#000000' }, ticks: ticks };
}
var tooltipLabel = function (context) {
var value = context.parsed && (context.parsed.y === undefined ? context.parsed : context.parsed.y);
var decimals = Number(chartOptions.decimal_places) || 0;
if (chartOptions.value_format === 'percent') return Number(value).toFixed(decimals) + '%';
if (chartOptions.value_format === 'currency') return '$' + Number(value).toFixed(decimals);
return Number(value).toLocaleString(undefined, { maximumFractionDigits: decimals });
};
return {
responsive: true,
maintainAspectRatio: false,
animation: false,
indexAxis: chartData.type === 'bar' && chartOptions.bar_orientation === 'horizontal' ? 'y' : 'x',
color: chartOptions.text_color || '#000000',
font: { size: textSize },
plugins: { legend: { display: legendDisplay, labels: { color: chartOptions.text_color || '#000000', font: { size: textSize } } }, tooltip: { titleColor: chartOptions.text_color || '#000000', bodyColor: chartOptions.text_color || '#000000', footerColor: chartOptions.text_color || '#000000', titleFont: { size: textSize }, bodyFont: { size: textSize }, footerFont: { size: textSize }, callbacks: { label: tooltipLabel } } },
scales: scales
};
}
root.pulseChartData = {
getPath: getPath,
normalizeConfig: normalizeConfig,
normalizeRows: normalizeRows,
fromContent: fromContent,
getChartOptions: getChartOptions
};
}());
+271
View File
@@ -0,0 +1,271 @@
// Chart region editor and preview helpers.
(function () {
var registry = window.pulseRegionTypes;
var chartData = window.pulseChartData;
var defaultSeriesColors = ['#36a269', '#3b82f6', '#f59e0b', '#ef4444', '#8b5cf6', '#14b8a6'];
function escapeHtml(value) {
return window.pulseRegionUtils && window.pulseRegionUtils.escapeHtml
? window.pulseRegionUtils.escapeHtml(value)
: String(value === undefined || value === null ? '' : value);
}
function parseRows(value) {
try {
var parsed = JSON.parse(String(value || '[]'));
return Array.isArray(parsed) ? parsed : [];
} catch (_error) {
return [];
}
}
function getCurrentConfig(region, existingContent) {
var current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
var config = chartData.normalizeConfig(current);
config.manual_rows_json = JSON.stringify(config.manual_rows, null, 2);
config.datasets_json = JSON.stringify(config.datasets, null, 2);
return config;
}
function renderPreview(region, content, context) {
var normalized = chartData.fromContent(content || {}, context && context.sources || []);
var chartId = 'chart-preview-' + region.id;
var label = escapeHtml((content && content.dataset_label) || 'Value');
return '<div class="template-region chart" style="width:100%;height:100%;overflow:hidden;position:relative;">' +
'<canvas id="' + chartId + '" data-chart-json="' + escapeHtml(JSON.stringify(normalized)) + '" aria-label="' + label + ' chart"></canvas>' +
'</div>';
}
function initPreview(root) {
if (typeof window.Chart !== 'function' || !root || !root.querySelectorAll) {
return;
}
root.querySelectorAll('.template-region.chart canvas').forEach(function (canvas) {
var data = {};
try { data = JSON.parse(canvas.getAttribute('data-chart-json') || '{}'); } catch (_error) { data = {}; }
new window.Chart(canvas.getContext('2d'), {
type: data.type || 'bar',
data: { labels: data.labels || [], datasets: data.datasets || [] },
options: chartData.getChartOptions(data)
});
});
}
function syncSourceFields(card) {
if (!card || !card.querySelector) {
return;
}
var sourceKind = card.querySelector('[name^="region_chart_source_kind_"]');
var isApi = sourceKind && sourceKind.value === 'api';
card.querySelectorAll('[data-chart-source-panel="api"], [data-chart-source-panel="manual"]').forEach(function (panel) {
var visible = panel.getAttribute('data-chart-source-panel') === (isApi ? 'api' : 'manual');
panel.hidden = !visible;
panel.querySelectorAll('input, select, textarea').forEach(function (input) {
input.disabled = !visible;
});
});
}
function renderDatasetRow(dataset, index, regionId) {
var current = dataset || {};
var hasColor = /^#[0-9a-fA-F]{3,8}$/.test(String(current.color || '').trim());
return '<div class="row g-2 align-items-end mb-2" data-chart-dataset-row>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_dataset_label_' + regionId + '_' + index + '">Series label</label><input id="region_chart_dataset_label_' + regionId + '_' + index + '" class="form-control" data-chart-dataset-label value="' + escapeHtml(current.label || 'Series ' + (index + 1)) + '" /></div>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_dataset_value_' + regionId + '_' + index + '">Value field</label><input id="region_chart_dataset_value_' + regionId + '_' + index + '" class="form-control" data-chart-dataset-value value="' + escapeHtml(current.value_path || '') + '" placeholder="sales" /></div>' +
'<div class="col-10 col-md-3"><label class="form-label" for="region_chart_dataset_color_' + regionId + '_' + index + '">Series color</label><input id="region_chart_dataset_color_' + regionId + '_' + index + '" class="form-control form-control-color w-100" data-chart-dataset-color data-chart-dataset-color-explicit="' + (hasColor ? 'true' : 'false') + '" type="color" value="' + escapeHtml(hasColor ? current.color : defaultSeriesColors[index % defaultSeriesColors.length]) + '" /></div>' +
'<div class="col-2 col-md-1"><button type="button" class="btn btn-outline-danger w-100" data-chart-dataset-remove aria-label="Remove series" title="Remove series">&times;</button></div>' +
'</div>';
}
function bindEditorCard(context) {
var card = context && context.card;
if (!card) {
return;
}
var sourceKind = card.querySelector('[name^="region_chart_source_kind_"]');
var chartType = card.querySelector('[name^="region_chart_type_"]');
var datasetEditor = card.querySelector('[data-chart-dataset-editor]');
var singleValueFields = card.querySelectorAll('[data-chart-single-value-field]');
if (sourceKind) {
sourceKind.addEventListener('change', function () {
syncSourceFields(card);
if (context.requestPreviewRender) context.requestPreviewRender();
});
}
function syncDatasetEditor() {
var usesDatasets = chartType && chartType.value === 'line';
if (datasetEditor) {
datasetEditor.hidden = !usesDatasets;
}
singleValueFields.forEach(function (field) {
field.hidden = usesDatasets;
});
card.querySelectorAll('[data-chart-option-types]').forEach(function (field) {
var types = String(field.getAttribute('data-chart-option-types') || '').split(' ');
var hidden = !chartType || types.indexOf(chartType.value) === -1;
field.hidden = hidden;
field.classList.toggle('d-none', hidden);
});
}
if (chartType) {
chartType.addEventListener('change', function () {
syncDatasetEditor();
if (context.requestPreviewRender) context.requestPreviewRender();
});
}
var regionId = card.getAttribute('data-region-id');
var datasetList = card.querySelector('[data-chart-datasets]');
var datasetInput = card.querySelector('[name="region_chart_datasets_' + regionId + '"]');
var addDatasetButton = card.querySelector('[data-chart-dataset-add]');
function syncDatasets() {
var datasets = [];
card.querySelectorAll('[data-chart-dataset-row]').forEach(function (row) {
var label = row.querySelector('[data-chart-dataset-label]');
var valuePath = row.querySelector('[data-chart-dataset-value]');
var color = row.querySelector('[data-chart-dataset-color]');
var explicitColor = color && color.getAttribute('data-chart-dataset-color-explicit') === 'true';
if (valuePath && valuePath.value.trim()) {
datasets.push({ label: label && label.value.trim() || 'Series ' + (datasets.length + 1), value_path: valuePath.value.trim(), color: explicitColor ? color.value : '' });
}
});
if (datasetInput) datasetInput.value = JSON.stringify(datasets);
if (context.requestPreviewRender) context.requestPreviewRender();
}
if (addDatasetButton && datasetList) {
addDatasetButton.addEventListener('click', function () {
var index = datasetList.querySelectorAll('[data-chart-dataset-row]').length;
datasetList.insertAdjacentHTML('beforeend', renderDatasetRow({ label: 'Series ' + (index + 1), value_path: '' }, index, regionId));
syncDatasets();
});
}
if (datasetList) {
datasetList.addEventListener('input', function (event) {
if (event.target.matches('[data-chart-dataset-color]')) {
event.target.setAttribute('data-chart-dataset-color-explicit', 'true');
}
syncDatasets();
});
datasetList.addEventListener('change', syncDatasets);
datasetList.addEventListener('click', function (event) {
var removeButton = event.target.closest('[data-chart-dataset-remove]');
if (!removeButton) return;
var rows = datasetList.querySelectorAll('[data-chart-dataset-row]');
if (rows.length > 1) removeButton.closest('[data-chart-dataset-row]').remove();
syncDatasets();
});
syncDatasets();
}
syncSourceFields(card);
syncDatasetEditor();
}
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
var config = context.config || {};
var chartOptions = config.chart_options || {};
var sourceOptions = config.source_options || {};
var datasets = config.datasets && config.datasets.length ? config.datasets : [{ label: config.dataset_label || 'Value', value_path: config.value_path || 'value', color: '' }];
var datasetRows = datasets.map(function (dataset, index) { return renderDatasetRow(dataset, index, region.id); }).join('');
var accordionId = 'region-chart-options-' + region.id;
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
'<div class="card-header template-field-head"><strong>' + escapeHtml(region.label) + '</strong><div class="template-field-actions"><span class="chip">Chart</span></div></div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="row g-3">' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_type_' + region.id + '">Chart type</label><select id="region_chart_type_' + region.id + '" name="region_chart_type_' + region.id + '" class="form-select"><option value="bar"' + (config.type === 'bar' ? ' selected' : '') + '>Bar</option><option value="line"' + (config.type === 'line' ? ' selected' : '') + '>Line</option><option value="doughnut"' + (config.type === 'doughnut' ? ' selected' : '') + '>Doughnut</option><option value="pie"' + (config.type === 'pie' ? ' selected' : '') + '>Pie</option></select></div>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_source_kind_' + region.id + '">Data source</label><select id="region_chart_source_kind_' + region.id + '" name="region_chart_source_kind_' + region.id + '" class="form-select"><option value="manual"' + (config.source_kind === 'manual' ? ' selected' : '') + '>Manual rows</option><option value="api"' + (config.source_kind === 'api' ? ' selected' : '') + '>API source</option></select></div>' +
'<div class="col-12 col-md-4" data-chart-source-panel="api"><label class="form-label" for="region_chart_source_id_' + region.id + '">API source</label><select id="region_chart_source_id_' + region.id + '" name="region_chart_source_id_' + region.id + '" class="form-select"><option value="">Select a source</option>' + String(context.sourceOptions || '') + '</select></div>' +
'</div>' +
'<div class="row g-3" data-chart-source-panel="api">' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_items_path_' + region.id + '">Items path</label><input id="region_chart_items_path_' + region.id + '" name="region_chart_items_path_' + region.id + '" class="form-control" value="' + escapeHtml(config.items_path) + '" placeholder="results" /></div>' +
'<div class="col-12 col-md-4" data-chart-single-value-field><label class="form-label" for="region_chart_label_path_' + region.id + '">Label field</label><input id="region_chart_label_path_' + region.id + '" name="region_chart_label_path_' + region.id + '" class="form-control" value="' + escapeHtml(config.label_path) + '" placeholder="date" /></div>' +
'<div class="col-12 col-md-4" data-chart-single-value-field><label class="form-label" for="region_chart_value_path_' + region.id + '">Single value field</label><input id="region_chart_value_path_' + region.id + '" name="region_chart_value_path_' + region.id + '" class="form-control" value="' + escapeHtml(config.value_path) + '" placeholder="value" /></div>' +
'</div>' +
'<div class="row g-3">' +
'<div class="col-12" data-chart-source-panel="manual"><label class="form-label" for="region_chart_manual_rows_' + region.id + '">Manual rows (JSON)</label><textarea id="region_chart_manual_rows_' + region.id + '" name="region_chart_manual_rows_' + region.id + '" class="form-control" rows="5">' + escapeHtml(config.manual_rows_json) + '</textarea><div class="form-text">Rows use a label and one or more mapped values, for example [{"label":"Mon","value":12,"color":"#36a269"}].</div></div>' +
'<div class="col-12" data-chart-dataset-editor' + (config.type === 'line' ? '' : ' hidden') + '><div class="d-flex align-items-center justify-content-between gap-2"><label class="form-label mb-0">Data series</label><button type="button" class="btn btn-outline-secondary btn-sm" data-chart-dataset-add><i class="bi bi-plus-lg" aria-hidden="true"></i> Add series</button></div><div class="mt-2" data-chart-datasets>' + datasetRows + '</div><input type="hidden" name="region_chart_datasets_' + region.id + '" value="' + escapeHtml(JSON.stringify(datasets)) + '" /><div class="form-text">Add one section per API field or manual value. Set the color for each series here.</div></div>' +
'</div>' +
'<div class="accordion" id="' + accordionId + '">' +
'<div class="accordion-item overflow-hidden"><h2 class="accordion-header" id="' + accordionId + '-heading-chart"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#' + accordionId + '-chart" aria-expanded="false" aria-controls="' + accordionId + '-chart">Chart options</button></h2><div id="' + accordionId + '-chart" class="accordion-collapse collapse" aria-labelledby="' + accordionId + '-heading-chart" data-bs-parent="#' + accordionId + '"><div class="accordion-body"><div class="row g-3">' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_legend_' + region.id + '">Legend</label><select id="region_chart_legend_' + region.id + '" name="region_chart_legend_' + region.id + '" class="form-select"><option value="automatic"' + (chartOptions.legend === 'automatic' ? ' selected' : '') + '>Automatic</option><option value="show"' + (chartOptions.legend === 'show' ? ' selected' : '') + '>Show</option><option value="hide"' + (chartOptions.legend === 'hide' ? ' selected' : '') + '>Hide</option></select></div>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_value_format_' + region.id + '">Value format</label><select id="region_chart_value_format_' + region.id + '" name="region_chart_value_format_' + region.id + '" class="form-select"><option value="number"' + (chartOptions.value_format === 'number' ? ' selected' : '') + '>Number</option><option value="percent"' + (chartOptions.value_format === 'percent' ? ' selected' : '') + '>Percent</option><option value="currency"' + (chartOptions.value_format === 'currency' ? ' selected' : '') + '>Currency</option></select></div>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_decimal_places_' + region.id + '">Decimal places</label><input id="region_chart_decimal_places_' + region.id + '" name="region_chart_decimal_places_' + region.id + '" class="form-control" type="number" min="0" max="4" value="' + escapeHtml(chartOptions.decimal_places === undefined ? 0 : chartOptions.decimal_places) + '" /></div>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_text_size_' + region.id + '">Text size (px)</label><input id="region_chart_text_size_' + region.id + '" name="region_chart_text_size_' + region.id + '" class="form-control" type="number" min="8" max="48" step="1" value="' + escapeHtml(chartOptions.text_size || 12) + '" /></div>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_grid_color_' + region.id + '">Grid line color</label><input id="region_chart_grid_color_' + region.id + '" name="region_chart_grid_color_' + region.id + '" class="form-control form-control-color" type="color" value="' + escapeHtml(chartOptions.grid_color || '#000000') + '" title="Choose grid line color" /></div>' +
'<div class="col-12 col-md-4"><label class="form-label" for="region_chart_text_color_' + region.id + '">Text color</label><input id="region_chart_text_color_' + region.id + '" name="region_chart_text_color_' + region.id + '" class="form-control form-control-color" type="color" value="' + escapeHtml(chartOptions.text_color || '#000000') + '" title="Choose chart text color" /></div>' +
'<div class="col-12 col-md-4" data-chart-option-types="line bar"><label class="form-label" for="region_chart_y_axis_min_' + region.id + '">Y-axis minimum</label><input id="region_chart_y_axis_min_' + region.id + '" name="region_chart_y_axis_min_' + region.id + '" class="form-control" type="number" value="' + escapeHtml(chartOptions.y_axis_min) + '" placeholder="Automatic" /></div>' +
'<div class="col-12 col-md-4" data-chart-option-types="line bar"><label class="form-label" for="region_chart_y_axis_max_' + region.id + '">Y-axis maximum</label><input id="region_chart_y_axis_max_' + region.id + '" name="region_chart_y_axis_max_' + region.id + '" class="form-control" type="number" value="' + escapeHtml(chartOptions.y_axis_max) + '" placeholder="Automatic" /></div>' +
'<div class="col-12 col-md-4 d-flex align-items-end" data-chart-option-types="line bar"><div class="form-check mb-2"><input type="hidden" name="region_chart_begin_at_zero_control_' + region.id + '" value="1" /><input id="region_chart_begin_at_zero_' + region.id + '" name="region_chart_begin_at_zero_' + region.id + '" class="form-check-input" type="checkbox"' + (chartOptions.begin_at_zero !== false ? ' checked' : '') + ' /><label class="form-check-label" for="region_chart_begin_at_zero_' + region.id + '">Start Y-axis at zero</label></div></div>' +
'<div class="col-12 col-md-4" data-chart-option-types="line"><label class="form-label" for="region_chart_line_points_' + region.id + '">Line points</label><select id="region_chart_line_points_' + region.id + '" name="region_chart_line_points_' + region.id + '" class="form-select"><option value="automatic"' + (chartOptions.line_points === 'automatic' ? ' selected' : '') + '>Automatic</option><option value="show"' + (chartOptions.line_points === 'show' ? ' selected' : '') + '>Show</option><option value="hide"' + (chartOptions.line_points === 'hide' ? ' selected' : '') + '>Hide</option></select></div>' +
'<div class="col-12 col-md-4" data-chart-option-types="bar"><label class="form-label" for="region_chart_bar_orientation_' + region.id + '">Bar orientation</label><select id="region_chart_bar_orientation_' + region.id + '" name="region_chart_bar_orientation_' + region.id + '" class="form-select"><option value="vertical"' + (chartOptions.bar_orientation !== 'horizontal' ? ' selected' : '') + '>Vertical</option><option value="horizontal"' + (chartOptions.bar_orientation === 'horizontal' ? ' selected' : '') + '>Horizontal</option></select></div>' +
'<div class="col-12 col-md-4 d-flex align-items-end" data-chart-option-types="line"><div class="form-check mb-2"><input id="region_chart_line_fill_' + region.id + '" name="region_chart_line_fill_' + region.id + '" class="form-check-input" type="checkbox"' + (chartOptions.line_fill ? ' checked' : '') + ' /><label class="form-check-label" for="region_chart_line_fill_' + region.id + '">Fill line area</label></div></div>' +
'</div></div></div></div>' +
'<div class="accordion-item overflow-hidden"><h2 class="accordion-header" id="' + accordionId + '-heading-source"><button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#' + accordionId + '-source" aria-expanded="false" aria-controls="' + accordionId + '-source">Source options</button></h2><div id="' + accordionId + '-source" class="accordion-collapse collapse" aria-labelledby="' + accordionId + '-heading-source" data-bs-parent="#' + accordionId + '"><div class="accordion-body"><div class="row g-3">' +
'<div class="col-12 col-md-3"><label class="form-label" for="region_chart_source_sort_' + region.id + '">Order</label><select id="region_chart_source_sort_' + region.id + '" name="region_chart_source_sort_' + region.id + '" class="form-select"><option value="none"' + (sourceOptions.sort === 'none' ? ' selected' : '') + '>Original order</option><option value="reverse-original"' + (sourceOptions.sort === 'reverse-original' ? ' selected' : '') + '>Reverse original order</option><option value="value-ascending"' + (sourceOptions.sort === 'value-ascending' ? ' selected' : '') + '>Increasing values</option><option value="value-descending"' + (sourceOptions.sort === 'value-descending' ? ' selected' : '') + '>Decreasing values</option></select></div>' +
'<div class="col-12 col-md-3"><label class="form-label" for="region_chart_source_limit_' + region.id + '">Maximum items</label><input id="region_chart_source_limit_' + region.id + '" name="region_chart_source_limit_' + region.id + '" class="form-control" type="number" min="0" max="500" value="' + escapeHtml(sourceOptions.limit || 0) + '" placeholder="All" /></div>' +
'<div class="col-12 col-md-3"><label class="form-label" for="region_chart_source_missing_' + region.id + '">Missing values</label><select id="region_chart_source_missing_' + region.id + '" name="region_chart_source_missing_' + region.id + '" class="form-select"><option value="gap"' + (sourceOptions.missing === 'gap' ? ' selected' : '') + '>Leave gaps</option><option value="skip"' + (sourceOptions.missing === 'skip' ? ' selected' : '') + '>Skip row</option><option value="zero"' + (sourceOptions.missing === 'zero' ? ' selected' : '') + '>Use zero</option></select></div>' +
'</div></div></div></div>' +
'</div>' +
'</div>' +
'</div>';
}
function buildPreviewRenderContext(region, card, existingContent, sources) {
var current = getCurrentConfig(region, existingContent || {});
function value(name, fallback) {
var input = card && card.querySelector ? card.querySelector('[name="' + name + '_' + region.id + '"]') : null;
return input ? input.value : fallback;
}
function checked(name, fallback) {
var input = card && card.querySelector ? card.querySelector('[name="' + name + '_' + region.id + '"]') : null;
return input ? input.checked : fallback;
}
return {
type: 'chart',
chart_type: value('region_chart_type', current.type),
source_kind: value('region_chart_source_kind', current.source_kind),
source_id: value('region_chart_source_id', current.source_id),
items_path: value('region_chart_items_path', current.items_path),
label_path: value('region_chart_label_path', current.label_path),
value_path: value('region_chart_value_path', current.value_path),
dataset_label: value('region_chart_dataset_label', current.dataset_label),
color: value('region_chart_color', current.color),
datasets: parseRows(value('region_chart_datasets', current.datasets_json)),
manual_rows: parseRows(value('region_chart_manual_rows', current.manual_rows_json)),
chart_options: {
legend: value('region_chart_legend', current.chart_options.legend),
begin_at_zero: checked('region_chart_begin_at_zero', current.chart_options.begin_at_zero),
y_axis_min: value('region_chart_y_axis_min', current.chart_options.y_axis_min),
y_axis_max: value('region_chart_y_axis_max', current.chart_options.y_axis_max),
line_points: value('region_chart_line_points', current.chart_options.line_points),
line_fill: checked('region_chart_line_fill', current.chart_options.line_fill),
bar_orientation: value('region_chart_bar_orientation', current.chart_options.bar_orientation),
value_format: value('region_chart_value_format', current.chart_options.value_format),
decimal_places: value('region_chart_decimal_places', current.chart_options.decimal_places),
text_size: value('region_chart_text_size', current.chart_options.text_size),
grid_color: value('region_chart_grid_color', current.chart_options.grid_color),
text_color: value('region_chart_text_color', current.chart_options.text_color)
},
source_options: {
sort: value('region_chart_source_sort', current.source_options.sort),
limit: value('region_chart_source_limit', current.source_options.limit),
reverse: false,
missing: value('region_chart_source_missing', current.source_options.missing)
},
sources: Array.isArray(sources) ? sources : []
};
}
registry.register('chart', {
label: 'Chart',
getDefaultRegionSize: function () { return { width: 480, height: 280 }; },
getCurrentConfig: getCurrentConfig,
renderPreview: renderPreview,
initPreview: initPreview,
bindEditorCard: bindEditorCard,
renderEditorCard: renderEditorCard,
buildPreviewRenderContext: buildPreviewRenderContext
});
}());
+7 -15
View File
@@ -42,14 +42,6 @@ function getColumnsPerRow() {
return window.innerWidth >= 1200 ? 6 : window.innerWidth >= 992 ? 3 : window.innerWidth >= 576 ? 2 : 1;
}
function showUploadMessage(message) {
if (typeof window.showToast === 'function') {
window.showToast(message, 'warning');
return;
}
window.alert(message);
}
function showModal(target) {
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
window.pulseModal.show(target);
@@ -229,12 +221,12 @@ function setUploadFile(file, shouldCrop) {
const matchesType = allowedTypes.length === 0 || allowedTypes.indexOf(fileType) !== -1;
const maxBytes = fileType.indexOf('video/') === 0 ? Number(uploadInput.dataset.videoMaxBytes) : Number(uploadInput.dataset.imageMaxBytes);
if (!matchesType) {
showUploadMessage('This media type is disabled in Media Uploads settings.');
window.PulseUpload.showMessage('This media type is disabled in Media Uploads settings.');
uploadInput.value = '';
return;
}
if (maxBytes && file.size > maxBytes) {
showUploadMessage('This file is larger than the configured upload limit.');
window.PulseUpload.showMessage('This file is larger than the current upload limit of ' + window.PulseUpload.formatUploadLimit(maxBytes) + '.');
uploadInput.value = '';
return;
}
@@ -310,7 +302,7 @@ if (cropApply) {
const outputType = cropSourceFile.type === 'image/jpeg' || cropSourceFile.type === 'image/webp' ? cropSourceFile.type : 'image/png';
cropper.getCroppedCanvas().toBlob(function (blob) {
if (!blob) {
showUploadMessage('Unable to crop this image.');
window.PulseUpload.showMessage('Unable to crop this image.');
return;
}
const extension = outputType === 'image/jpeg' ? '.jpg' : outputType === 'image/webp' ? '.webp' : '.png';
@@ -372,7 +364,7 @@ if (uploadForm) {
uploadForm.addEventListener('submit', function (event) {
if (uploadInput && !uploadInput.files.length) {
event.preventDefault();
showUploadMessage('Choose an image or video to upload.');
window.PulseUpload.showMessage('Choose an image or video to upload.');
}
});
}
@@ -476,7 +468,7 @@ async function refreshGallery() {
visibleRowLimit = 4;
updateSearchResults();
} catch (error) {
showUploadMessage(error.message || 'Unable to refresh media.');
window.PulseUpload.showMessage(error.message || 'Unable to refresh media.');
}
}
@@ -499,7 +491,7 @@ if (gallery) {
event.preventDefault();
event.stopPropagation();
if (column.dataset.mediaUsed === 'true') {
showUploadMessage('This media is in use and cannot be selected.');
window.PulseUpload.showMessage('This media is in use and cannot be selected.');
return;
}
const id = String(column.dataset.mediaId || '');
@@ -525,7 +517,7 @@ if (loadMoreButton) {
visibleRowLimit += 4;
updateSearchResults();
} catch (error) {
showUploadMessage(error.message || 'Unable to load more media.');
window.PulseUpload.showMessage(error.message || 'Unable to load more media.');
} finally {
loadMoreButton.disabled = false;
}
+21
View File
@@ -0,0 +1,21 @@
(function (window) {
var uploadUtils = window.PulseUpload || {};
uploadUtils.formatUploadLimit = function (bytes) {
var megabytes = Number(bytes || 0) / 1024 / 1024;
if (megabytes >= 1024 && megabytes % 1024 === 0) {
return (megabytes / 1024) + ' GB';
}
return (Number.isInteger(megabytes) ? megabytes : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
};
uploadUtils.showMessage = function (message) {
if (typeof window.showToast === 'function') {
window.showToast(message, 'warning');
return;
}
window.alert(message);
};
window.PulseUpload = uploadUtils;
})(window);
@@ -615,7 +615,7 @@ export function createSlideFormEditorController(options) {
promotion: false,
relative_urls: false,
remove_script_host: false,
convert_urls: true,
convert_urls: false,
paste_data_images: false,
paste_block_drop: true,
resize: false,
+10 -6
View File
@@ -539,7 +539,7 @@ export function createSlideFormRegionHelpers(options) {
if (module && typeof module.buildEditorCardContext === 'function') {
return module.buildEditorCardContext({
region: region,
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' || region.region_type === 'weather' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' || region.region_type === 'weather' || region.region_type === 'chart' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
currentDuration: getCurrentRegionVideoDuration(region),
regionRatio: reduceAspectRatio(region.width, region.height),
uploadMaxLabel: uploadMaxLabel,
@@ -548,7 +548,7 @@ export function createSlideFormRegionHelpers(options) {
uploadVideoMaxBytes: uploadVideoMaxBytes,
uploadMimeTypes: uploadMimeTypes,
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: (existingContent[region.region_key] || {}).weather_location_id || '' } : getCurrentRssConfig(region),
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: (existingContent[region.region_key] || {}).weather_location_id || '' } : region.region_type === 'chart' && module && typeof module.getCurrentConfig === 'function' ? module.getCurrentConfig(region, existingContent) : getCurrentRssConfig(region),
style: getCurrentTextStyle(region),
fontSize: getCurrentTextStyle(region).font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -584,7 +584,7 @@ export function createSlideFormRegionHelpers(options) {
return {
region: region,
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'weather' ? currentContent : getCurrentRegionValue(region),
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'weather' || region.region_type === 'chart' ? currentContent : getCurrentRegionValue(region),
currentDuration: getCurrentRegionVideoDuration(region),
regionRatio: reduceAspectRatio(region.width, region.height),
uploadMaxLabel: uploadMaxLabel,
@@ -593,7 +593,7 @@ export function createSlideFormRegionHelpers(options) {
uploadVideoMaxBytes: uploadVideoMaxBytes,
uploadMimeTypes: uploadMimeTypes,
disableAudio: currentContent.disable_audio,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: currentContent.weather_location_id || '' } : rssConfig,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : region.region_type === 'weather' ? { weather_location_id: currentContent.weather_location_id || '' } : region.region_type === 'chart' ? ((getRegionTypeModule('chart') || {}).getCurrentConfig ? getRegionTypeModule('chart').getCurrentConfig(region, existingContent) : {}) : rssConfig,
style: textStyle,
fontSize: textStyle.font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -636,11 +636,15 @@ export function createSlideFormRegionHelpers(options) {
Array.prototype.forEach.call(templateFields.querySelectorAll('[data-region-id]'), function (card) {
var module = null;
if (!card || !card.querySelector || !card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
if (!card || !card.querySelector) {
return;
}
module = getRegionTypeModule('qr-code');
if (card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
module = getRegionTypeModule('qr-code');
} else if (card.querySelector('select[name^="region_chart_source_kind_"]')) {
module = getRegionTypeModule('chart');
}
if (module && typeof module.bindEditorCard === 'function') {
module.bindEditorCard({
+10 -11
View File
@@ -6,14 +6,6 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
(function () {
var DEFAULT_FONT_SIZE = 32;
function formatUploadLimitLabel(bytes) {
var megabytes = Number(bytes || 0) / 1024 / 1024;
if (megabytes >= 1024 && megabytes % 1024 === 0) {
return String(megabytes / 1024) + ' GB';
}
return (Number.isInteger(megabytes) ? String(megabytes) : megabytes.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')) + ' MB';
}
var dataElement = document.getElementById('slide-editor-data');
if (!dataElement) {
return;
@@ -48,11 +40,11 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var openPreviewPopupButton = document.getElementById('open-preview-popup');
var uploadLimits = slideEditorData.uploadLimits || {};
var uploadMaxBytes = Number(uploadLimits.imageMaxBytes || 100 * 1024 * 1024);
var uploadMaxLabel = formatUploadLimitLabel(uploadMaxBytes);
var uploadMaxLabel = window.PulseUpload.formatUploadLimit(uploadMaxBytes);
var uploadVideoMaxBytes = Number(uploadLimits.videoMaxBytes || 1024 * 1024 * 1024);
var uploadVideoMaxLabel = formatUploadLimitLabel(uploadVideoMaxBytes);
var uploadVideoMaxLabel = window.PulseUpload.formatUploadLimit(uploadVideoMaxBytes);
var wysiwygImageUploadMaxBytes = Number(uploadLimits.wysiwygImageMaxBytes || 2 * 1024 * 1024);
var wysiwygImageUploadLimitLabel = formatUploadLimitLabel(wysiwygImageUploadMaxBytes);
var wysiwygImageUploadLimitLabel = window.PulseUpload.formatUploadLimit(wysiwygImageUploadMaxBytes);
var uploadMimeTypes = Array.isArray(uploadLimits.allowedMimeTypes) ? uploadLimits.allowedMimeTypes : [];
var videoDurationCache = Object.create(null);
var previewRenderFrame = 0;
@@ -690,6 +682,13 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
}).join('');
regions.forEach(function (region) {
var typeModule = getRegionTypeModule(region.region_type);
if (typeModule && typeof typeModule.initPreview === 'function') {
typeModule.initPreview(slidePreviewOverlay);
}
});
startPreviewVideoPlayback(slidePreviewOverlay);
syncPopupPreview();
@@ -1778,6 +1778,9 @@
}
if (backgroundInput) {
backgroundInput.addEventListener('change', function () {
if (backgroundInput.dataset.uploadUrl) {
return;
}
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
+14
View File
@@ -0,0 +1,14 @@
/*!
* Chart.js v4.5.1
* https://www.chartjs.org
* (c) 2025 Chart.js Contributors
* Released under the MIT License
*/
!function(t,e){"object"==typeof exports&&"undefined"!=typeof module?module.exports=e():"function"==typeof define&&define.amd?define(e):(t="undefined"!=typeof globalThis?globalThis:t||self).Chart=e()}(this,(function(){"use strict";var t=Object.freeze({__proto__:null,get Colors(){return Jo},get Decimation(){return ta},get Filler(){return ba},get Legend(){return Ma},get SubTitle(){return Pa},get Title(){return ka},get Tooltip(){return Na}});function e(){}const i=(()=>{let t=0;return()=>t++})();function s(t){return null==t}function n(t){if(Array.isArray&&Array.isArray(t))return!0;const e=Object.prototype.toString.call(t);return"[object"===e.slice(0,7)&&"Array]"===e.slice(-6)}function o(t){return null!==t&&"[object Object]"===Object.prototype.toString.call(t)}function a(t){return("number"==typeof t||t instanceof Number)&&isFinite(+t)}function r(t,e){return a(t)?t:e}function l(t,e){return void 0===t?e:t}const h=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100:+t/e,c=(t,e)=>"string"==typeof t&&t.endsWith("%")?parseFloat(t)/100*e:+t;function d(t,e,i){if(t&&"function"==typeof t.call)return t.apply(i,e)}function u(t,e,i,s){let a,r,l;if(n(t))if(r=t.length,s)for(a=r-1;a>=0;a--)e.call(i,t[a],a);else for(a=0;a<r;a++)e.call(i,t[a],a);else if(o(t))for(l=Object.keys(t),r=l.length,a=0;a<r;a++)e.call(i,t[l[a]],l[a])}function f(t,e){let i,s,n,o;if(!t||!e||t.length!==e.length)return!1;for(i=0,s=t.length;i<s;++i)if(n=t[i],o=e[i],n.datasetIndex!==o.datasetIndex||n.index!==o.index)return!1;return!0}function g(t){if(n(t))return t.map(g);if(o(t)){const e=Object.create(null),i=Object.keys(t),s=i.length;let n=0;for(;n<s;++n)e[i[n]]=g(t[i[n]]);return e}return t}function p(t){return-1===["__proto__","prototype","constructor"].indexOf(t)}function m(t,e,i,s){if(!p(t))return;const n=e[t],a=i[t];o(n)&&o(a)?x(n,a,s):e[t]=g(a)}function x(t,e,i){const s=n(e)?e:[e],a=s.length;if(!o(t))return t;const r=(i=i||{}).merger||m;let l;for(let e=0;e<a;++e){if(l=s[e],!o(l))continue;const n=Object.keys(l);for(let e=0,s=n.length;e<s;++e)r(n[e],t,l,i)}return t}function b(t,e){return x(t,e,{merger:_})}function _(t,e,i){if(!p(t))return;const s=e[t],n=i[t];o(s)&&o(n)?b(s,n):Object.prototype.hasOwnProperty.call(e,t)||(e[t]=g(n))}const y={"":t=>t,x:t=>t.x,y:t=>t.y};function v(t){const e=t.split("."),i=[];let s="";for(const t of e)s+=t,s.endsWith("\\")?s=s.slice(0,-1)+".":(i.push(s),s="");return i}function M(t,e){const i=y[e]||(y[e]=function(t){const e=v(t);return t=>{for(const i of e){if(""===i)break;t=t&&t[i]}return t}}(e));return i(t)}function w(t){return t.charAt(0).toUpperCase()+t.slice(1)}const k=t=>void 0!==t,S=t=>"function"==typeof t,P=(t,e)=>{if(t.size!==e.size)return!1;for(const i of t)if(!e.has(i))return!1;return!0};function D(t){return"mouseup"===t.type||"click"===t.type||"contextmenu"===t.type}const C=Math.PI,O=2*C,A=O+C,T=Number.POSITIVE_INFINITY,L=C/180,E=C/2,R=C/4,I=2*C/3,z=Math.log10,F=Math.sign;function V(t,e,i){return Math.abs(t-e)<i}function B(t){const e=Math.round(t);t=V(t,e,t/1e3)?e:t;const i=Math.pow(10,Math.floor(z(t))),s=t/i;return(s<=1?1:s<=2?2:s<=5?5:10)*i}function W(t){const e=[],i=Math.sqrt(t);let s;for(s=1;s<i;s++)t%s==0&&(e.push(s),e.push(t/s));return i===(0|i)&&e.push(i),e.sort(((t,e)=>t-e)).pop(),e}function N(t){return!function(t){return"symbol"==typeof t||"object"==typeof t&&null!==t&&!(Symbol.toPrimitive in t||"toString"in t||"valueOf"in t)}(t)&&!isNaN(parseFloat(t))&&isFinite(t)}function H(t,e){const i=Math.round(t);return i-e<=t&&i+e>=t}function j(t,e,i){let s,n,o;for(s=0,n=t.length;s<n;s++)o=t[s][i],isNaN(o)||(e.min=Math.min(e.min,o),e.max=Math.max(e.max,o))}function $(t){return t*(C/180)}function Y(t){return t*(180/C)}function U(t){if(!a(t))return;let e=1,i=0;for(;Math.round(t*e)/e!==t;)e*=10,i++;return i}function X(t,e){const i=e.x-t.x,s=e.y-t.y,n=Math.sqrt(i*i+s*s);let o=Math.atan2(s,i);return o<-.5*C&&(o+=O),{angle:o,distance:n}}function q(t,e){return Math.sqrt(Math.pow(e.x-t.x,2)+Math.pow(e.y-t.y,2))}function K(t,e){return(t-e+A)%O-C}function G(t){return(t%O+O)%O}function J(t,e,i,s){const n=G(t),o=G(e),a=G(i),r=G(o-n),l=G(a-n),h=G(n-o),c=G(n-a);return n===o||n===a||s&&o===a||r>l&&h<c}function Z(t,e,i){return Math.max(e,Math.min(i,t))}function Q(t){return Z(t,-32768,32767)}function tt(t,e,i,s=1e-6){return t>=Math.min(e,i)-s&&t<=Math.max(e,i)+s}function et(t,e,i){i=i||(i=>t[i]<e);let s,n=t.length-1,o=0;for(;n-o>1;)s=o+n>>1,i(s)?o=s:n=s;return{lo:o,hi:n}}const it=(t,e,i,s)=>et(t,i,s?s=>{const n=t[s][e];return n<i||n===i&&t[s+1][e]===i}:s=>t[s][e]<i),st=(t,e,i)=>et(t,i,(s=>t[s][e]>=i));function nt(t,e,i){let s=0,n=t.length;for(;s<n&&t[s]<e;)s++;for(;n>s&&t[n-1]>i;)n--;return s>0||n<t.length?t.slice(s,n):t}const ot=["push","pop","shift","splice","unshift"];function at(t,e){t._chartjs?t._chartjs.listeners.push(e):(Object.defineProperty(t,"_chartjs",{configurable:!0,enumerable:!1,value:{listeners:[e]}}),ot.forEach((e=>{const i="_onData"+w(e),s=t[e];Object.defineProperty(t,e,{configurable:!0,enumerable:!1,value(...eLine truncated
/*!
* @kurkle/color v0.3.2
* https://github.com/kurkle/color#readme
* (c) 2023 Jukka Kurkela
* Released under the MIT License
*/function _t(t){return t+.5|0}const yt=(t,e,i)=>Math.max(Math.min(t,i),e);function vt(t){return yt(_t(2.55*t),0,255)}function Mt(t){return yt(_t(255*t),0,255)}function wt(t){return yt(_t(t/2.55)/100,0,1)}function kt(t){return yt(_t(100*t),0,100)}const St={0:0,1:1,2:2,3:3,4:4,5:5,6:6,7:7,8:8,9:9,A:10,B:11,C:12,D:13,E:14,F:15,a:10,b:11,c:12,d:13,e:14,f:15},Pt=[..."0123456789ABCDEF"],Dt=t=>Pt[15&t],Ct=t=>Pt[(240&t)>>4]+Pt[15&t],Ot=t=>(240&t)>>4==(15&t);function At(t){var e=(t=>Ot(t.r)&&Ot(t.g)&&Ot(t.b)&&Ot(t.a))(t)?Dt:Ct;return t?"#"+e(t.r)+e(t.g)+e(t.b)+((t,e)=>t<255?e(t):"")(t.a,e):void 0}const Tt=/^(hsla?|hwb|hsv)\(\s*([-+.e\d]+)(?:deg)?[\s,]+([-+.e\d]+)%[\s,]+([-+.e\d]+)%(?:[\s,]+([-+.e\d]+)(%)?)?\s*\)$/;function Lt(t,e,i){const s=e*Math.min(i,1-i),n=(e,n=(e+t/30)%12)=>i-s*Math.max(Math.min(n-3,9-n,1),-1);return[n(0),n(8),n(4)]}function Et(t,e,i){const s=(s,n=(s+t/60)%6)=>i-i*e*Math.max(Math.min(n,4-n,1),0);return[s(5),s(3),s(1)]}function Rt(t,e,i){const s=Lt(t,1,.5);let n;for(e+i>1&&(n=1/(e+i),e*=n,i*=n),n=0;n<3;n++)s[n]*=1-e-i,s[n]+=e;return s}function It(t){const e=t.r/255,i=t.g/255,s=t.b/255,n=Math.max(e,i,s),o=Math.min(e,i,s),a=(n+o)/2;let r,l,h;return n!==o&&(h=n-o,l=a>.5?h/(2-n-o):h/(n+o),r=function(t,e,i,s,n){return t===n?(e-i)/s+(e<i?6:0):e===n?(i-t)/s+2:(t-e)/s+4}(e,i,s,h,n),r=60*r+.5),[0|r,l||0,a]}function zt(t,e,i,s){return(Array.isArray(e)?t(e[0],e[1],e[2]):t(e,i,s)).map(Mt)}function Ft(t,e,i){return zt(Lt,t,e,i)}function Vt(t){return(t%360+360)%360}function Bt(t){const e=Tt.exec(t);let i,s=255;if(!e)return;e[5]!==i&&(s=e[6]?vt(+e[5]):Mt(+e[5]));const n=Vt(+e[2]),o=+e[3]/100,a=+e[4]/100;return i="hwb"===e[1]?function(t,e,i){return zt(Rt,t,e,i)}(n,o,a):"hsv"===e[1]?function(t,e,i){return zt(Et,t,e,i)}(n,o,a):Ft(n,o,a),{r:i[0],g:i[1],b:i[2],a:s}}const Wt={x:"dark",Z:"light",Y:"re",X:"blu",W:"gr",V:"medium",U:"slate",A:"ee",T:"ol",S:"or",B:"ra",C:"lateg",D:"ights",R:"in",Q:"turquois",E:"hi",P:"ro",O:"al",N:"le",M:"de",L:"yello",F:"en",K:"ch",G:"arks",H:"ea",I:"ightg",J:"wh"},Nt={OiceXe:"f0f8ff",antiquewEte:"faebd7",aqua:"ffff",aquamarRe:"7fffd4",azuY:"f0ffff",beige:"f5f5dc",bisque:"ffe4c4",black:"0",blanKedOmond:"ffebcd",Xe:"ff",XeviTet:"8a2be2",bPwn:"a52a2a",burlywood:"deb887",caMtXe:"5f9ea0",KartYuse:"7fff00",KocTate:"d2691e",cSO:"ff7f50",cSnflowerXe:"6495ed",cSnsilk:"fff8dc",crimson:"dc143c",cyan:"ffff",xXe:"8b",xcyan:"8b8b",xgTMnPd:"b8860b",xWay:"a9a9a9",xgYF:"6400",xgYy:"a9a9a9",xkhaki:"bdb76b",xmagFta:"8b008b",xTivegYF:"556b2f",xSange:"ff8c00",xScEd:"9932cc",xYd:"8b0000",xsOmon:"e9967a",xsHgYF:"8fbc8f",xUXe:"483d8b",xUWay:"2f4f4f",xUgYy:"2f4f4f",xQe:"ced1",xviTet:"9400d3",dAppRk:"ff1493",dApskyXe:"bfff",dimWay:"696969",dimgYy:"696969",dodgerXe:"1e90ff",fiYbrick:"b22222",flSOwEte:"fffaf0",foYstWAn:"228b22",fuKsia:"ff00ff",gaRsbSo:"dcdcdc",ghostwEte:"f8f8ff",gTd:"ffd700",gTMnPd:"daa520",Way:"808080",gYF:"8000",gYFLw:"adff2f",gYy:"808080",honeyMw:"f0fff0",hotpRk:"ff69b4",RdianYd:"cd5c5c",Rdigo:"4b0082",ivSy:"fffff0",khaki:"f0e68c",lavFMr:"e6e6fa",lavFMrXsh:"fff0f5",lawngYF:"7cfc00",NmoncEffon:"fffacd",ZXe:"add8e6",ZcSO:"f08080",Zcyan:"e0ffff",ZgTMnPdLw:"fafad2",ZWay:"d3d3d3",ZgYF:"90ee90",ZgYy:"d3d3d3",ZpRk:"ffb6c1",ZsOmon:"ffa07a",ZsHgYF:"20b2aa",ZskyXe:"87cefa",ZUWay:"778899",ZUgYy:"778899",ZstAlXe:"b0c4de",ZLw:"ffffe0",lime:"ff00",limegYF:"32cd32",lRF:"faf0e6",magFta:"ff00ff",maPon:"800000",VaquamarRe:"66cdaa",VXe:"cd",VScEd:"ba55d3",VpurpN:"9370db",VsHgYF:"3cb371",VUXe:"7b68ee",VsprRggYF:"fa9a",VQe:"48d1cc",VviTetYd:"c71585",midnightXe:"191970",mRtcYam:"f5fffa",mistyPse:"ffe4e1",moccasR:"ffe4b5",navajowEte:"ffdead",navy:"80",Tdlace:"fdf5e6",Tive:"808000",TivedBb:"6b8e23",Sange:"ffa500",SangeYd:"ff4500",ScEd:"da70d6",pOegTMnPd:"eee8aa",pOegYF:"98fb98",pOeQe:"afeeee",pOeviTetYd:"db7093",papayawEp:"ffefd5",pHKpuff:"ffdab9",peru:"cd853f",pRk:"ffc0cb",plum:"dda0dd",powMrXe:"b0e0e6",purpN:"800080",YbeccapurpN:"663399",Yd:"ff0000",Psybrown:"bc8f8f",PyOXe:"4169e1",saddNbPwn:"8b4513",sOmon:"fa8072",sandybPwn:"f4a460",sHgYF:"2e8b57",sHshell:"fff5ee",siFna:"a0522d",silver:"c0c0c0",skyXe:"87ceeb",UXe:"6a5acd",UWay:"708090",UgYy:"708090",snow:"fffafa",sprRggYF:"ff7f",stAlXe:"4682b4",tan:"d2b48c",teO:"8080",tEstN:"d8bfd8",tomato:"ff6347",Qe:"40e0d0",viTet:"ee82ee",JHt:"f5deb3",wEte:"ffffff",wEtesmoke:"f5f5f5",Lw:"ffff00",LwgYF:"9acd32"};let Ht;function jt(t){Ht||(Ht=function(){const t={},e=Object.keys(Nt),i=Object.keys(Wt);let s,n,o,a,r;for(s=0;s<e.length;s++){for(a=r=e[s],n=0;n<i.length;n++)o=i[n],r=r.replace(o,Wt[o]);o=parseInt(Nt[a],16),t[r]=[o>>16&255,o>>8&255,255&o]}return t}(),Ht.transparent=[0,0,0,0]);const e=Ht[t.toLowerCase()];return e&&{r:e[0],g:e[1],b:e[2],a:4===e.length?e[3]:255}}const $t=/^rgba?\(\s*([-+.\d]+)(%)?[\s,]+([-+.e\d]+)(%)?[\s,]+([-+.e\d]+)(%)?(?:[\s,/]+([-+.e\d]+)(%)?)?\s*\)$/;const Yt=t=>t<=.0031308?12.92*t:1.055*Math.pow(t,1/2.4)-.055,Ut=t=>t<=.04045?t/12.92:Math.pow((t+.055)/1.055,2.4);function Xt(t,e,i){if(t){let s=It(t);s[e]=Math.max(0,Math.min(s[e]+s[e]*i,0===e?360:1)),Line truncated
//# sourceMappingURL=chart.umd.js.map
+1 -1
View File
@@ -1,6 +1,6 @@
// Account password page renderer and return URL normalization.
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
function normalizeReturnUrl(value) {
const url = String(value || '').trim();
+10 -14
View File
@@ -1,6 +1,9 @@
// Admin client command routes for connected screens.
// Client routing should rely on the shared player registration helpers instead of recreating
// URL and heartbeat checks in each route, which makes the screen-target selection consistent.
const { createPageAuthToken } = require('#src/request-auth');
const { normalizeBaseUrl } = require('#src/data/player-registry');
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
@@ -31,17 +34,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
function normalizeExplicitPlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
return normalizeBaseUrl(value);
}
function normalizeConnectionBaseUrl(connection) {
return normalizeExplicitPlayerBaseUrl(connection && connection.playerPublicBaseUrl);
}
function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
async function fetchPlayerRegistrations() {
if (typeof common.fetchPlayerRegistrations !== 'function') {
return [];
@@ -87,6 +86,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
});
}
// Resolve a screen's live connections back to the player's internal base URL when the public URL
// is known to the registry. This lets the admin UI target the correct local endpoint without
// depending on whichever public URL happened to be reported by the connection itself.
async function resolvePlayerBaseUrlsForConnections(connections) {
const connectionList = Array.isArray(connections) ? connections : [];
const seen = new Set();
@@ -119,6 +121,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return normalizeExplicitPlayerBaseUrl(matchedPlayer && matchedPlayer.internal_base_url) || normalizedPublicBaseUrl;
}
// Admin commands may target either a direct player connection or a bridged device. Keep the fan-out
// logic explicit so a screen can be controlled through the correct transport without mixing both
// path choices into the command payload itself.
async function forwardPlayerCommandForConnections(screenSlug, connections, commandPayload, connectionId, deviceId) {
const connectionList = Array.isArray(connections) ? connections : [];
const targetedConnections = connectionId
@@ -186,15 +191,6 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return forwardPlayerCommand(screenSlug, commandPayload, connectionId || deviceId || undefined);
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
try {
const slug = String(req.params.slug || '').trim();
+84 -22
View File
@@ -7,7 +7,7 @@ const { fetchAppSettings } = require('#src/data/app-settings');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const { buildAuditChanges } = require('#src/data/audit-log');
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { fetchMediaAssets, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
const { fetchMediaAssets, createImageVariants, registerMediaAssets, syncMediaAssetsFromDirectory, publishMediaAssets, removePendingMediaAssets } = require('#src/web/lib/media/library');
module.exports = function registerContentRoutes(app, deps) {
const pool = deps.pool;
@@ -126,6 +126,7 @@ module.exports = function registerContentRoutes(app, deps) {
throw error;
}
}
return settings;
}
function requireSlideUploadPermission(req, res, next) {
@@ -147,6 +148,25 @@ module.exports = function registerContentRoutes(app, deps) {
next(error);
}
function requireTemplateUploadPermission(req, res, next) {
if (!req.currentUser) {
if (typeof setAuthMessageCookie === 'function') {
setAuthMessageCookie(res, 'Please sign in to continue.');
return res.redirect('/login');
}
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['templates.create', 'templates.update'])) {
return next();
}
const error = new Error(PERMISSION_DENIED_MESSAGE);
error.statusCode = 403;
error.expose = true;
next(error);
}
function getUploadedFileMediaType(file, uploadContext) {
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
@@ -233,6 +253,23 @@ module.exports = function registerContentRoutes(app, deps) {
}
}
async function processImmediateMediaUpload(req, res, uploadContext) {
if (!req.file) {
return res.status(400).json({ error: 'No file was uploaded.' });
}
await validateUploadedFiles([req.file], uploadContext);
await createImageVariants(req.file);
await registerMediaAssets(pool, [req.file], function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req), { published: false });
res.json({
path: '/media/uploads/' + req.file.filename,
filename: req.file.filename,
originalName: req.file.originalname || ''
});
}
async function fetchTemplateSlideCount(templateId) {
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM c_slides WHERE template_id = ?', [templateId]);
return Number(rows[0] && rows[0].slide_count) || 0;
@@ -268,7 +305,7 @@ module.exports = function registerContentRoutes(app, deps) {
return Array.from(usage);
}
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../signage/templates/duplicate');
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('#src/web/routes/signage/templates/duplicate');
async function fetchSlidesByTemplateId(templateId) {
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
@@ -291,6 +328,7 @@ module.exports = function registerContentRoutes(app, deps) {
}
const canvasSizes = await common.fetchCanvasSizesData(pool);
const settings = await fetchAppSettings(pool);
let duplicateName = buildDuplicateTemplateName(template.name);
let duplicateIndex = 2;
while (await common.fetchDuplicateName(pool, 'c_templates', duplicateName)) {
@@ -298,10 +336,11 @@ module.exports = function registerContentRoutes(app, deps) {
duplicateIndex += 1;
}
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser));
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser, null, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
} catch (error) {
next(error);
}
});
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
@@ -445,22 +484,8 @@ module.exports = function registerContentRoutes(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.' });
}
const uploadContext = String(req.get('X-Upload-Context') || req.query.context || '').trim().toLowerCase();
await validateUploadedFiles([req.file], uploadContext);
await registerMediaAssets(pool, [req.file], function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req), { published: false });
res.json({
path: '/media/uploads/' + req.file.filename,
filename: req.file.filename,
originalName: req.file.originalname || ''
});
await processImmediateMediaUpload(req, res, uploadContext);
} catch (error) {
next(error);
}
@@ -485,9 +510,39 @@ module.exports = function registerContentRoutes(app, deps) {
}
});
app.post('/templates/uploads', requireTemplateUploadPermission, upload.single('file'), async function (req, res, next) {
try {
await processImmediateMediaUpload(req, res, 'template-background');
} catch (error) {
next(error);
}
});
app.post('/templates/uploads/cleanup', requireTemplateUploadPermission, async function (req, res, next) {
try {
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
? req.body.uploadPaths
: req.body && req.body.uploadPaths
? [req.body.uploadPaths]
: [];
if (!uploadPaths.length) {
return res.status(400).json({ error: 'No upload paths were provided.' });
}
await removePendingMediaAssets(pool, deps.uploadDir, uploadPaths);
res.sendStatus(204);
} catch (error) {
next(error);
}
});
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const settings = await validateUploadedFiles(req.files || []);
for (const file of req.files || []) {
await createImageVariants(file);
}
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
@@ -523,7 +578,10 @@ module.exports = function registerContentRoutes(app, deps) {
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const settings = await validateUploadedFiles(req.files || []);
for (const file of req.files || []) {
await createImageVariants(file);
}
await registerMediaAssets(pool, req.files, function (file) {
return '/media/uploads/' + file.filename;
}, getUploadedFileMediaType, getAuditUserId(req));
@@ -632,7 +690,8 @@ module.exports = function registerContentRoutes(app, deps) {
const data = await common.fetchCanvasSizesData(pool);
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24);
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, mediaAssets));
const settings = await fetchAppSettings(pool);
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, mediaAssets, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
} catch (error) {
next(error);
}
@@ -663,6 +722,7 @@ module.exports = function registerContentRoutes(app, deps) {
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
await syncPlaylistUploadsOnChange({
key: 'template:create:' + result.insertId,
pool: pool,
@@ -691,7 +751,8 @@ module.exports = function registerContentRoutes(app, deps) {
const sizeData = await common.fetchCanvasSizesData(pool);
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
const mediaAssets = await fetchMediaAssets(pool);
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, mediaAssets));
const settings = await fetchAppSettings(pool);
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, mediaAssets, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES));
} catch (error) {
next(error);
}
@@ -745,6 +806,7 @@ module.exports = function registerContentRoutes(app, deps) {
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
await syncPlaylistUploadsOnChange({
key: 'template:update:' + template.id,
pool: pool,
+4 -9
View File
@@ -1,6 +1,9 @@
// Admin manage routes for screens and commands.
// Screen command dispatch should not duplicate the global player-registration rules; this route
// intentionally relies on the shared registry helpers for consistent URL and freshness checks.
const { buildAuditChanges } = require('#src/data/audit-log');
const { normalizeBaseUrl } = require('#src/data/player-registry');
module.exports = function registerManageRoutes(app, deps) {
const pool = deps.pool;
@@ -31,7 +34,7 @@ module.exports = function registerManageRoutes(app, deps) {
}
function normalizeExplicitPlayerBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
return normalizeBaseUrl(value);
}
async function fetchPlayerRegistrations() {
@@ -47,14 +50,6 @@ module.exports = function registerManageRoutes(app, deps) {
}
}
function isRecentPlayerRegistration(player, staleSeconds) {
const lastSeenAt = player && player.last_seen_at;
const lastSeenAtValue = lastSeenAt instanceof Date ? lastSeenAt.getTime() : new Date(lastSeenAt).getTime();
const cutoffTime = Date.now() - Math.max(30, Number(staleSeconds || 60)) * 1000;
return Number.isFinite(lastSeenAtValue) && lastSeenAtValue >= cutoffTime;
}
async function fetchLiveConnectionsForScreen(slug) {
if (typeof getScreenConnections !== 'function') {
return [];
+7 -7
View File
@@ -1,11 +1,11 @@
// Admin page route composition for the signage sections.
module.exports = function registerPagesRoutes(app, deps) {
require('../signage/dashboard/routes')(app, deps);
require('../signage/clients/routes')(app, deps);
require('../signage/screens/routes')(app, deps);
require('../signage/playlists/routes')(app, deps);
require('../signage/slides/routes')(app, deps);
require('../signage/canvas-sizes/routes')(app, deps);
require('../signage/templates/routes')(app, deps);
require('#src/web/routes/signage/dashboard/routes')(app, deps);
require('#src/web/routes/signage/clients/routes')(app, deps);
require('#src/web/routes/signage/screens/routes')(app, deps);
require('#src/web/routes/signage/playlists/routes')(app, deps);
require('#src/web/routes/signage/slides/routes')(app, deps);
require('#src/web/routes/signage/canvas-sizes/routes')(app, deps);
require('#src/web/routes/signage/templates/routes')(app, deps);
};
+2 -2
View File
@@ -12,9 +12,9 @@
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
const readArrayField = deps.readArrayField;
const normalizePermissionKeys = deps.normalizePermissionKeys;
const { buildPagination } = require('../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const requirePermission = deps.requirePermission;
const { buildDuplicateRoleName, buildDuplicateRole } = require('../settings/rbac/duplicate');
const { buildDuplicateRoleName, buildDuplicateRole } = require('#src/web/routes/settings/rbac/duplicate');
const ROLE_NAME_MAX_LENGTH = 255;
const ROLE_DESCRIPTION_MAX_LENGTH = 255;
+2 -2
View File
@@ -19,9 +19,9 @@
const validatePasswordStrength = deps.validatePasswordStrength;
const readArrayField = deps.readArrayField;
const rbacData = deps.rbacData;
const { buildPagination } = require('../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const requirePermission = deps.requirePermission;
const { buildDuplicateUserName, buildDuplicateUser } = require('../settings/users/duplicate');
const { buildDuplicateUserName, buildDuplicateUser } = require('#src/web/routes/settings/users/duplicate');
const USER_NAME_MAX_LENGTH = 255;
const USER_USERNAME_MAX_LENGTH = 255;
+1 -1
View File
@@ -1,4 +1,4 @@
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderAcceptInvitePage(message, token, email, name, passwordRequirements, isValidInvitation) {
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
@@ -1,4 +1,4 @@
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderEmailVerificationErrorPage(message) {
return renderView('auth/email-verification-error', { title: 'Verification link unavailable', authShell: true, bodyClass: 'login-page-body', message: message || 'This email verification link is invalid or has expired.' });
+1 -1
View File
@@ -1,4 +1,4 @@
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderEmailVerifiedPage() {
return renderView('auth/email-verified', { title: 'Email verified', authShell: true, bodyClass: 'login-page-body' });
+1 -1
View File
@@ -1,4 +1,4 @@
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderForgotPasswordPage(message) {
return renderView('auth/forgot-password', { title: 'Reset password', authShell: true, bodyClass: 'login-page-body', message: message || '' });
+1 -1
View File
@@ -1,6 +1,6 @@
// Authentication route registration for the web app.
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
const { normalizeReturnToPath, getRequestOrigin } = require('#src/web/lib/auth/session');
const { fetchAppSettings } = require('#src/data/app-settings');
const { formatAccountEmailExpiry, renderAccountEmailTemplate } = require('#src/data/account-email-templates');
+1 -1
View File
@@ -1,6 +1,6 @@
// Sign-in page renderer for the authentication flow.
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderLoginPage(message, returnTo, username, showForgotPassword) {
return renderView('auth/login', {
+1 -1
View File
@@ -1,4 +1,4 @@
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderResetPasswordPage(message, token, passwordRequirements) {
const requirements = passwordRequirements || { minimumLength: 10, minimumCategories: 3, requireLowercase: false, requireUppercase: false, requireNumber: false, requireSymbol: false };
@@ -1,7 +1,7 @@
// API source add page renderer.
const { renderView } = require('../../../view');
const { buildApiSourceFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildApiSourceFormViewModel } = require('#src/web/routes/data-sources/api-sources/form-view-model');
module.exports = function renderApiSourceAddPage(apiSource, message, currentUser, options) {
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, options, false));
@@ -1,7 +1,7 @@
// API source edit page renderer.
const { renderView } = require('../../../view');
const { buildApiSourceFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildApiSourceFormViewModel } = require('#src/web/routes/data-sources/api-sources/form-view-model');
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
const viewData = Object.assign({
@@ -1,6 +1,6 @@
// API source list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
function toIsoTimestamp(value) {
if (!value) {
@@ -1,10 +1,10 @@
// API source route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderApiSourcesPage = require('./list');
const renderApiSourceAddPage = require('./add');
const renderApiSourceEditPage = require('./edit');
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
const { buildPagination } = require('#src/web/lib/pagination');
const renderApiSourcesPage = require('#src/web/routes/data-sources/api-sources/list');
const renderApiSourceAddPage = require('#src/web/routes/data-sources/api-sources/add');
const renderApiSourceEditPage = require('#src/web/routes/data-sources/api-sources/edit');
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('#src/web/routes/data-sources/api-sources/duplicate');
const { fetchAppSettings } = require('#src/data/app-settings');
function toIsoTimestamp(value) {
+2 -2
View File
@@ -1,7 +1,7 @@
// RSS feed add page renderer.
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildRssFeedFormViewModel } = require('#src/web/routes/data-sources/rss-feeds/form-view-model');
module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) {
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false));
@@ -1,7 +1,7 @@
// RSS feed edit page renderer.
const { renderView } = require('../../../view');
const { buildRssFeedFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildRssFeedFormViewModel } = require('#src/web/routes/data-sources/rss-feeds/form-view-model');
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
const viewData = Object.assign({
@@ -1,6 +1,6 @@
// RSS feed list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
function formatIntervalLabel(interval, unit) {
const value = Math.max(1, Number(interval) || 0);
@@ -1,10 +1,10 @@
// RSS feed route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderRssFeedsPage = require('./list');
const renderRssFeedAddPage = require('./add');
const renderRssFeedEditPage = require('./edit');
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
const { buildPagination } = require('#src/web/lib/pagination');
const renderRssFeedsPage = require('#src/web/routes/data-sources/rss-feeds/list');
const renderRssFeedAddPage = require('#src/web/routes/data-sources/rss-feeds/add');
const renderRssFeedEditPage = require('#src/web/routes/data-sources/rss-feeds/edit');
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('#src/web/routes/data-sources/rss-feeds/duplicate');
const { fetchAppSettings } = require('#src/data/app-settings');
async function getDataSourceUsageMaps(pool, common) {
@@ -1,7 +1,7 @@
// Timetable group add page renderer.
const { renderView } = require('../../../view');
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildTimetableGroupFormViewModel } = require('#src/web/routes/data-sources/timetables/form-view-model');
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser, options) {
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false, options));
@@ -1,7 +1,7 @@
// Timetable group edit page renderer.
const { renderView } = require('../../../view');
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildTimetableGroupFormViewModel } = require('#src/web/routes/data-sources/timetables/form-view-model');
module.exports = function renderTimetableGroupEditPage(timetableGroup, timetableEntries, message, currentUser) {
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, true));
@@ -1,6 +1,6 @@
// Timetable group list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
const { normalizeTimeZone } = require('#src/data/timetables');
function formatDateInTimeZone(value, timeZone) {
@@ -1,10 +1,10 @@
// Timetable route registration.
const { buildPagination } = require('../../../lib/pagination');
const renderTimetableGroupsPage = require('./list');
const renderTimetableGroupAddPage = require('./add');
const renderTimetableGroupEditPage = require('./edit');
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('./duplicate');
const { buildPagination } = require('#src/web/lib/pagination');
const renderTimetableGroupsPage = require('#src/web/routes/data-sources/timetables/list');
const renderTimetableGroupAddPage = require('#src/web/routes/data-sources/timetables/add');
const renderTimetableGroupEditPage = require('#src/web/routes/data-sources/timetables/edit');
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('#src/web/routes/data-sources/timetables/duplicate');
async function getDataSourceUsageMaps(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
+7 -7
View File
@@ -1,12 +1,12 @@
// Weather location source CRUD routes.
const { buildPagination } = require('../../lib/pagination');
const renderWeatherLocationsPage = require('./weather/list');
const renderWeatherLocationAddPage = require('./weather/add');
const renderWeatherLocationEditPage = require('./weather/edit');
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('./weather/duplicate');
const { fetchAppSettings } = require('../../../data/app-settings');
const { buildAuditChanges } = require('../../../data/audit-log');
const { buildPagination } = require('#src/web/lib/pagination');
const renderWeatherLocationsPage = require('#src/web/routes/data-sources/weather/list');
const renderWeatherLocationAddPage = require('#src/web/routes/data-sources/weather/add');
const renderWeatherLocationEditPage = require('#src/web/routes/data-sources/weather/edit');
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('#src/web/routes/data-sources/weather/duplicate');
const { fetchAppSettings } = require('#src/data/app-settings');
const { buildAuditChanges } = require('#src/data/audit-log');
async function getWeatherLocationUsageIds(pool, common) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
+2 -2
View File
@@ -1,3 +1,3 @@
const { renderView } = require('../../../view');
const buildWeatherLocationFormViewModel = require('./form-view-model');
const { renderView } = require('#src/web/view');
const buildWeatherLocationFormViewModel = require('#src/web/routes/data-sources/weather/form-view-model');
module.exports = function renderWeatherLocationAddPage(location, message, currentUser, options) { return renderView('data-sources/weather/form', buildWeatherLocationFormViewModel(location, message, currentUser, false, options)); };
+2 -2
View File
@@ -1,3 +1,3 @@
const { renderView } = require('../../../view');
const buildWeatherLocationFormViewModel = require('./form-view-model');
const { renderView } = require('#src/web/view');
const buildWeatherLocationFormViewModel = require('#src/web/routes/data-sources/weather/form-view-model');
module.exports = function renderWeatherLocationEditPage(location, message, currentUser, options) { return renderView('data-sources/weather/form', buildWeatherLocationFormViewModel(location, message, currentUser, true, options)); };
@@ -1,4 +1,4 @@
const { convertWeatherSnapshot } = require('../../../../data/weather-units');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
function buildDefaultWeatherLocation() {
return { id: null, enabled: 1, name: '', location_label: '', latitude: '', longitude: '', timezone: 'Europe/London', provider: 'open-meteo', temperature_unit: 'celsius', wind_unit: 'kmh', precipitation_unit: 'mm', update_interval_value: 30, update_interval_unit: 'minutes' };
+1 -1
View File
@@ -1,4 +1,4 @@
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
function formatInterval(value, unit) {
const amount = Math.max(1, Number(value) || 0);
+1 -1
View File
@@ -2,7 +2,7 @@
const { verifyRequestAuth } = require('#src/request-auth');
const { collectFontLibrarySyncOperations } = require('#src/web/lib/media/font-library');
const { collectLocalControlUsers } = require('../../lib/local-control-users');
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
function requireRequestAuth(req, res, next) {
if (!verifyRequestAuth(req)) {
+1 -1
View File
@@ -2,7 +2,7 @@
const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { renderView } = require('../view');
const { renderView } = require('#src/web/view');
function wait(milliseconds) {
return new Promise(function (resolve) {
+24 -24
View File
@@ -1,28 +1,28 @@
// Compose the primary web route registrations.
const registerAuthRoutes = require('./auth');
const registerPagesRoutes = require('./admin/pages');
const registerAccountRoutes = require('./admin/account');
const registerUsersRoutes = require('./admin/users');
const registerPlaylistRoutes = require('./admin/playlists');
const registerManageRoutes = require('./admin/manage');
const registerScreenCommandRoutes = require('./admin/client-commands');
const registerAnnouncementRoutes = require('./signage/announcements/routes');
const registerContentRoutes = require('./admin/content');
const registerRootDataSourceRoutes = require('./admin/data-sources');
const registerApiSourceRoutes = require('./data-sources/api-sources/routes');
const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
const registerTimetableRoutes = require('./data-sources/timetables/routes');
const registerWeatherRoutes = require('./data-sources/weather');
const registerSettingsRoutes = require('./settings/background-tasks');
const registerFontRoutes = require('./settings/fonts');
const registerMediaLibraryRoutes = require('./settings/media-library');
const registerSettingsPageRoutes = require('./settings/routes');
const registerAuditLogRoutes = require('./settings/audit-log');
const registerAboutRoutes = require('./settings/about/routes');
const registerInternalSyncRoutes = require('./internal/sync');
const registerScreensRoutes = require('./signage/screens/routes');
const registerOnboardingRoutes = require('./onboarding');
const registerAuthRoutes = require('#src/web/routes/auth/index');
const registerPagesRoutes = require('#src/web/routes/admin/pages');
const registerAccountRoutes = require('#src/web/routes/admin/account');
const registerUsersRoutes = require('#src/web/routes/admin/users');
const registerPlaylistRoutes = require('#src/web/routes/admin/playlists');
const registerManageRoutes = require('#src/web/routes/admin/manage');
const registerScreenCommandRoutes = require('#src/web/routes/admin/client-commands');
const registerAnnouncementRoutes = require('#src/web/routes/signage/announcements/routes');
const registerContentRoutes = require('#src/web/routes/admin/content');
const registerRootDataSourceRoutes = require('#src/web/routes/admin/data-sources');
const registerApiSourceRoutes = require('#src/web/routes/data-sources/api-sources/routes');
const registerRssFeedRoutes = require('#src/web/routes/data-sources/rss-feeds/routes');
const registerTimetableRoutes = require('#src/web/routes/data-sources/timetables/routes');
const registerWeatherRoutes = require('#src/web/routes/data-sources/weather');
const registerSettingsRoutes = require('#src/web/routes/settings/background-tasks');
const registerFontRoutes = require('#src/web/routes/settings/fonts');
const registerMediaLibraryRoutes = require('#src/web/routes/settings/media-library');
const registerSettingsPageRoutes = require('#src/web/routes/settings/routes');
const registerAuditLogRoutes = require('#src/web/routes/settings/audit-log');
const registerAboutRoutes = require('#src/web/routes/settings/about/routes');
const registerInternalSyncRoutes = require('#src/web/routes/internal/sync');
const registerScreensRoutes = require('#src/web/routes/signage/screens/routes');
const registerOnboardingRoutes = require('#src/web/routes/onboarding');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
function registerRoutes(app, deps) {
@@ -209,7 +209,7 @@ function registerSignageRoutes(app, deps) {
function registerSettingsAndContentRoutes(app, deps) {
// Content, data-source, RBAC, and settings routes share the admin-side services.
const registerRbacRoutes = require('./admin/rbac');
const registerRbacRoutes = require('#src/web/routes/admin/rbac');
registerRbacRoutes(app, {
pool: deps.pool,
+1 -1
View File
@@ -2,7 +2,7 @@
const fs = require('fs');
const path = require('path');
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
const packageLock = require('#root/package-lock.json');
const tinyMcePackage = require('#root/src/web/public/vendor/tinymce/package.json');
+2 -2
View File
@@ -3,8 +3,8 @@
const fs = require('fs');
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const fontLibrary = require('#src/web/lib/media/font-library');
const { buildPagination } = require('../../lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('../../lib/list-query');
const { buildPagination } = require('#src/web/lib/pagination');
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
const LIST_PAGE_SIZE = 25;
+1 -1
View File
@@ -1,6 +1,6 @@
// Managed fonts settings page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderFontsPage(data, message, currentUser) {
return renderView('settings/fonts/list', {
+1 -1
View File
@@ -1,6 +1,6 @@
// Visual-only application settings page.
const { renderView } = require('../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderSettingsPage(settings, suggestedAnnouncementIcons, currentUser, message, mediaSettings, diagnostics) {
const iconOptions = Array.isArray(suggestedAnnouncementIcons) ? suggestedAnnouncementIcons : [];
+1 -1
View File
@@ -1,6 +1,6 @@
// Pending invitations page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderInvitationsPage(data, currentUser) {
return renderView('settings/invitations/list', {
+2
View File
@@ -195,6 +195,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
: Math.round(maxBytes / (1024 * 1024)) + ' MB';
return res.redirect('/media-library?message=' + encodeURIComponent('File must be ' + limitLabel + ' or smaller.'));
}
await mediaLibrary.createImageVariants(req.file);
await mediaLibrary.registerMediaAsset(deps.pool, Object.assign({}, req.file, { mediaType: mediaType }), '/media/uploads/' + req.file.filename, deps.getAuditUserId(req));
res.redirect('/media-library?message=' + encodeURIComponent('Media uploaded.'));
} catch (error) {
@@ -222,6 +223,7 @@ module.exports = function registerMediaLibraryRoutes(app, deps) {
throw error;
}
});
await mediaLibrary.removeImageVariants(filePath);
if (typeof deps.syncPlaylistUploadsOnChange === 'function') {
await deps.syncPlaylistUploadsOnChange({
key: 'media-library:delete:' + asset.id,
@@ -1,6 +1,6 @@
// Media library page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderMediaLibraryPage(data, message, currentUser) {
return renderView('settings/media-library/list', {
+2 -2
View File
@@ -1,7 +1,7 @@
// RBAC role add page renderer.
const { renderView } = require('../../../view');
const { buildRbacAddViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildRbacAddViewModel } = require('#src/web/routes/settings/rbac/form-view-model');
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
return renderView('settings/rbac/form', buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant));
+2 -2
View File
@@ -1,7 +1,7 @@
// RBAC role edit page renderer.
const { renderView } = require('../../../view');
const { buildRbacEditViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildRbacEditViewModel } = require('#src/web/routes/settings/rbac/form-view-model');
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
return renderView('settings/rbac/form', buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination));
+1 -1
View File
@@ -1,6 +1,6 @@
// RBAC role-and-permission list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderRbacPage(data, message, currentUser) {
return renderView('settings/rbac/list', {
+2 -2
View File
@@ -1,7 +1,7 @@
// User add page renderer.
const { renderView } = require('../../../view');
const { buildUsersAddViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildUsersAddViewModel } = require('#src/web/routes/settings/users/form-view-model');
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
return renderView('settings/users/form', buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant));
+2 -2
View File
@@ -1,7 +1,7 @@
// User edit page renderer.
const { renderView } = require('../../../view');
const { buildUsersEditViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildUsersEditViewModel } = require('#src/web/routes/settings/users/form-view-model');
module.exports = function renderUsersEditPage(user, message, currentUser, roles, sessions) {
return renderView('settings/users/form', buildUsersEditViewModel(user, message, currentUser, roles, sessions));
+1 -1
View File
@@ -1,4 +1,4 @@
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderUsersInvitePage(message, currentUser, roles, formValues, messageVariant) {
return renderView('settings/users/invite', {
+1 -1
View File
@@ -1,6 +1,6 @@
// User list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderUsersPage(data, message, currentUser) {
return renderView('settings/users/list', {
+2 -2
View File
@@ -1,7 +1,7 @@
// Announcement add page renderer.
const { renderView } = require('../../../view');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildAnnouncementFormViewModel } = require('#src/web/routes/signage/announcements/form-view-model');
module.exports = function renderAnnouncementAddPage(announcement, message, currentUser, options) {
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, false));
+2 -2
View File
@@ -1,7 +1,7 @@
// Announcement edit page renderer.
const { renderView } = require('../../../view');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildAnnouncementFormViewModel } = require('#src/web/routes/signage/announcements/form-view-model');
module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) {
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, true));
+1 -1
View File
@@ -1,6 +1,6 @@
// Announcement list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
const { ANNOUNCEMENT_ICON_LABELS, DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
function formatDurationLabel(seconds) {
@@ -8,11 +8,11 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const requirePermission = deps.requirePermission;
const { buildPagination } = require('../../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const announcementData = require('#src/data/announcements');
const announcementIcons = require('#src/data/announcement-icons');
const { fetchAppSettings } = require('#src/data/app-settings');
const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('./duplicate');
const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('#src/web/routes/signage/announcements/duplicate');
const LIST_PAGE_SIZE = 25;
const ANNOUNCEMENT_TYPES = [
+2 -2
View File
@@ -1,7 +1,7 @@
// Canvas size add page renderer.
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildCanvasSizeFormViewModel } = require('#src/web/routes/signage/canvas-sizes/form-view-model');
module.exports = function renderCanvasSizeAddPage(canvasSize, message, currentUser) {
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, false));
+2 -2
View File
@@ -1,7 +1,7 @@
// Canvas size edit page renderer.
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildCanvasSizeFormViewModel } = require('#src/web/routes/signage/canvas-sizes/form-view-model');
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, true));
@@ -1,6 +1,6 @@
// Shared canvas size form view-model builder.
const { MAX_CANVAS_SIZE_DIMENSION } = require('#src/data');
const { MAX_CANVAS_SIZE_DIMENSION } = require('#src/data/index');
function buildDefaultCanvasSize() {
return {
+1 -1
View File
@@ -1,6 +1,6 @@
// Canvas size list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderCanvasSizesPage(data, message, currentUser) {
const canvasSizes = (data.canvasSizes || []).map((size) => ({
@@ -4,7 +4,7 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
const pool = deps.pool;
const { buildPagination } = require('../../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
+1 -1
View File
@@ -1,6 +1,6 @@
// Connected clients list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderConnectedClientsPage(data, message, currentUser) {
return renderView('clients/list', {
+2 -2
View File
@@ -5,8 +5,8 @@ module.exports = function registerClientsRoutes(app, deps) {
const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const { buildPagination } = require('../../../lib/pagination');
const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('../../../lib/list-query');
const { buildPagination } = require('#src/web/lib/pagination');
const { compareSortValues, createSearchMatcher, getComparableSortValue } = require('#src/web/lib/list-query');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
+1 -1
View File
@@ -1,6 +1,6 @@
// Dashboard page renderer for the signage overview.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderDashboardPage(data, message, currentUser) {
return renderView('dashboard/index', {
+2 -2
View File
@@ -1,6 +1,6 @@
// Playlist add page renderer.
const renderPlaylistEditorPage = require('./form-page');
const renderPlaylistEditorPage = require('#src/web/routes/signage/playlists/form-page');
module.exports = async function renderPlaylistFormPage(playlistOrMessage, messageOrCurrentUser, currentUserOrData, dataOrPool, poolMaybe) {
const hasPlaylistArgument = arguments.length >= 5;
@@ -9,7 +9,7 @@ module.exports = async function renderPlaylistFormPage(playlistOrMessage, messag
const currentUser = hasPlaylistArgument ? currentUserOrData : messageOrCurrentUser;
const data = hasPlaylistArgument ? dataOrPool : currentUserOrData;
const pool = hasPlaylistArgument ? poolMaybe : dataOrPool;
const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] });
const adminData = data || (pool ? await require('#src/data/index').fetchAdminData(pool) : { slides: [] });
return renderPlaylistEditorPage(playlist, adminData, message, currentUser, { isEdit: false });
};
+1 -1
View File
@@ -1,6 +1,6 @@
// Playlist edit page renderer.
const renderPlaylistEditorPage = require('./form-page');
const renderPlaylistEditorPage = require('#src/web/routes/signage/playlists/form-page');
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
return renderPlaylistEditorPage(playlist, data || {}, message, currentUser, { isEdit: true });
@@ -1,7 +1,7 @@
// Shared playlist form page renderer.
const { renderView } = require('../../../view');
const { buildPlaylistFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildPlaylistFormViewModel } = require('#src/web/routes/signage/playlists/form-view-model');
module.exports = function renderPlaylistFormPage(playlist, data, message, currentUser, options) {
return renderView('playlists/form', buildPlaylistFormViewModel(playlist || null, data || {}, message, currentUser, options || {}));
+1 -1
View File
@@ -1,6 +1,6 @@
// Playlist list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderPlaylistsPage(data, message, currentUser) {
const playlists = (data.playlists || []).map((playlist) => {
+2 -2
View File
@@ -5,9 +5,9 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
const common = deps.common;
const pages = deps.pages;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const { buildPagination } = require('../../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const requirePermission = deps.requirePermission;
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('#src/web/routes/signage/playlists/duplicate');
const { fetchAppSettings } = require('#src/data/app-settings');
const LIST_PAGE_SIZE = 25;
@@ -1,6 +1,6 @@
// Shared playlist slide configuration rendering helpers.
const { renderFragment } = require('../../../view');
const { renderFragment } = require('#src/web/view');
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
const DAY_OPTIONS = [
+2 -2
View File
@@ -1,7 +1,7 @@
// Screen add page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildScreenFormViewModel } = require('#src/web/routes/signage/screens/form-view-model');
module.exports = function renderScreenFormPage(data, message, currentUser) {
return renderView('screens/form', buildScreenFormViewModel(null, data || {}, message, currentUser, false));
+2 -2
View File
@@ -1,7 +1,7 @@
// Screen edit page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildScreenFormViewModel } = require('#src/web/routes/signage/screens/form-view-model');
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
return renderView('screens/form', buildScreenFormViewModel(screen, data || {}, message, currentUser, true));
+1 -1
View File
@@ -1,6 +1,6 @@
// Screen list page renderer.
const { renderView } = require('../../../view');
const { renderView } = require('#src/web/view');
module.exports = function renderScreensPage(data, message, currentUser) {
return renderView('screens/list', {
+1 -1
View File
@@ -8,7 +8,7 @@ module.exports = function registerScreensRoutes(app, deps) {
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const { buildPagination } = require('../../../lib/pagination');
const { buildPagination } = require('#src/web/lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 25;
+2 -2
View File
@@ -1,7 +1,7 @@
// Slide add page renderer.
const { renderView } = require('../../../view');
const { buildSlideFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildSlideFormViewModel } = require('#src/web/routes/signage/slides/form-view-model');
module.exports = function renderSlideAddPage(slideOrData, dataOrMessage, messageOrCurrentUser, currentUserMaybe) {
const hasSlideArgument = arguments.length >= 4;
+2 -2
View File
@@ -1,7 +1,7 @@
// Slide edit page renderer.
const { renderView } = require('../../../view');
const { buildSlideFormViewModel } = require('./form-view-model');
const { renderView } = require('#src/web/view');
const { buildSlideFormViewModel } = require('#src/web/routes/signage/slides/form-view-model');
module.exports = function renderSlideEditPage(data, slide, message, currentUser) {
return renderView('slides/form', buildSlideFormViewModel(data || {}, slide || null, message, currentUser, true));
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more