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
+4482 -1686

No files matched your search

+44
View File
@@ -2,6 +2,50 @@
All notable changes to this project will be documented in this file.
## 2.14.1 - 2026-10-11
### Added
- Added a configurable text size for chart axis labels, legends, and tooltips.
### Changed
- Standardized first-party Node.js imports on native `#src` and `#root` package aliases across the application and player/web bundle manifests, removing the process-wide resolver shim.
- Extracted player bridge socket and screen-snapshot state into a dedicated module.
- Consolidated player rich-text rendering, slide normalization, and media helpers so server and browser paths share implementations.
- Warmed the next slide's video element and promoted that same element when the slide becomes active, avoiding a duplicate video request.
- Deferred playlist updates until the current slide reaches its next transition, including forced refresh notifications.
- Limited announcement polling to WebSocket fallback and coalesced duplicate playlist refresh requests.
- Added content fingerprints to player runtime script URLs and refreshed those scripts network-first while retaining offline cache fallback.
### Fixed
- Restored missing player rendering helpers used by template slides, including animation decoration, media-source selection, and canvas sizing.
- Avoided a redundant playlist fetch on reload when cached playlist metadata is missing source data.
- Kept player reloads on the current screen when a reconnect encounters a transient client-ID conflict.
- Preserved safe video startup, pause, and resume behavior while applying queued playlist changes.
## 2.14.0 - 2026-10-03
### Added
- Added configurable chart data sources, multi-series line charts, chart type and source options, value ordering, grid line colors, and text colors across editor, popout preview, and player rendering.
- Added player playlist and playback recovery for cached snapshots, offline states, stalled media, failed image/video loads, and service-worker cache failures.
- Added deterministic image derivatives for large uploads while preserving the original file and preventing generated variants from exceeding the original dimensions or file size.
- Added immediate template background uploads with shared media validation, variant generation, cleanup of abandoned uploads, and permission checks.
### Changed
- Improved player handling of large images and videos across mixed-resolution deployments, including 8K displays.
- Hardened player render cancellation, preload cleanup, offline playlist options, and service-worker cache retention, and consolidated slide timing in the transition module.
- Separated player command handling from DOM transitions and region lifecycle dispatch.
- Cancelled obsolete video starts and readiness listeners during teardown, preserved outgoing video frames through crossfades, and guarded interrupted transitions against stale callbacks.
- Kept playlist refreshes from restarting active or paused slide timers and made service-worker cache writes best-effort so storage failures do not discard successful network responses.
- Updated upload validation to reject oversized files immediately and include the active configured limit in warning messages.
- Centralized shared browser upload formatting and notification helpers used by the media library, slide editor, and template editor.
- Removed the obsolete image-dimension setting and aligned media upload behavior across slides, templates, and the Media Library.
- Updated template background previews to reflect server-accepted uploads only, with failed and abandoned uploads cleaned up through the shared flow.
## 2.13.3 - 2026-09-13
### Fixed
+5 -1
View File
@@ -1,12 +1,16 @@
{
"name": "pulse-signage-player",
"version": "2.13.3",
"version": "2.14.1",
"private": false,
"description": "Pulse Signage player application bundle",
"engines": {
"node": ">=26.0.0"
},
"main": "src/common.js",
"imports": {
"#src/*": "./src/*.js",
"#root/*": "./*"
},
"scripts": {
"start": "node -r dotenv/config src/player.js",
"start:player": "node -r dotenv/config src/player.js"
+5 -1
View File
@@ -1,12 +1,16 @@
{
"name": "pulse-signage-web",
"version": "2.13.3",
"version": "2.14.1",
"private": false,
"description": "Pulse Signage web and bridge application bundle",
"engines": {
"node": ">=26.0.0"
},
"main": "src/common.js",
"imports": {
"#src/*": "./src/*.js",
"#root/*": "./*"
},
"scripts": {
"start": "node -r dotenv/config src/web.js",
"start:web": "node -r dotenv/config src/web.js"
+21 -2
View File
@@ -1,16 +1,17 @@
{
"name": "pulse-signage",
"version": "2.13.3",
"version": "2.14.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.13.3",
"version": "2.14.1",
"dependencies": {
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.13.1",
"chart.js": "^4.5.1",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
"express": "^5.2.1",
@@ -589,6 +590,12 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@kurkle/color": {
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/@kurkle/color/-/color-0.3.4.tgz",
"integrity": "sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==",
"license": "MIT"
},
"node_modules/@puppeteer/browsers": {
"version": "3.2.0",
"resolved": "https://registry.npmjs.org/@puppeteer/browsers/-/browsers-3.2.0.tgz",
@@ -1043,6 +1050,18 @@
"url": "https://github.com/sponsors/ljharb"
}
},
"node_modules/chart.js": {
"version": "4.5.1",
"resolved": "https://registry.npmjs.org/chart.js/-/chart.js-4.5.1.tgz",
"integrity": "sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==",
"license": "MIT",
"dependencies": {
"@kurkle/color": "^0.3.0"
},
"engines": {
"pnpm": ">=8"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.13.3",
"version": "2.14.1",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"engines": {
@@ -11,6 +11,10 @@
"url": "https://git.lzstealth.com/LZStealth/pulse-signage.git"
},
"main": "src/common.js",
"imports": {
"#src/*": "./src/*.js",
"#root/*": "./*"
},
"scripts": {
"start": "node -r dotenv/config src/web.js",
"start:web": "node -r dotenv/config src/web.js",
@@ -23,6 +27,7 @@
"@sparticuz/chromium": "^149.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.13.1",
"chart.js": "^4.5.1",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
"express": "^5.2.1",
+2 -25
View File
@@ -1,30 +1,7 @@
// Module-resolution shims for the app's `#root` and `#src` aliases.
const Module = require('module');
const path = require('path');
const rootDir = path.resolve(__dirname, '..');
const srcDir = __dirname;
const resolveFilename = Module._resolveFilename;
Module._resolveFilename = function (request, parent, isMain, options) {
if (request === '#root') {
request = rootDir;
} else if (request.startsWith('#root/')) {
request = path.join(rootDir, request.slice('#root/'.length));
} else if (request === '#src') {
request = srcDir;
} else if (request.startsWith('#src/')) {
request = path.join(srcDir, request.slice('#src/'.length));
}
return resolveFilename.call(this, request, parent, isMain, options);
};
const dbCommon = require('#src/db/common');
const db = require('#src/db');
const db = require('#src/db/index');
const dbBootstrap = require('#src/db/bootstrap');
const data = require('#src/data');
const data = require('#src/data/index');
const player = require('#src/player/render');
const listQuery = require('#src/web/lib/list-query');
const { fetchPlaylistCanvasId } = require('#src/web/lib/helpers');
+1 -1
View File
@@ -1,6 +1,6 @@
// Admin data aggregation helpers for dashboards, playlists, slides, templates, and screens.
const { fetchPagedRows } = require('./utils');
const { fetchPagedRows } = require('#src/data/utils');
async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
+3 -3
View File
@@ -1,12 +1,12 @@
// Announcement data access helpers.
const { fetchPagedRows } = require('./utils');
const { fetchPagedRows } = require('#src/data/utils');
const {
ANNOUNCEMENT_ICON_KEYS,
DEFAULT_ANNOUNCEMENT_ICON,
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
} = require('./announcement-icons');
const { validateMaxLength } = require('./utils');
} = require('#src/data/announcement-icons');
const { validateMaxLength } = require('#src/data/utils');
const SHORT_LABEL_MAX_LENGTH = 255;
+1 -1
View File
@@ -2,7 +2,7 @@
const http = require('http');
const https = require('https');
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
+1 -1
View File
@@ -1,6 +1,6 @@
// Application-wide settings defaults and persistence helpers.
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('./announcement-icons');
const { DEFAULT_ANNOUNCEMENT_ICON_KEYS } = require('#src/data/announcement-icons');
const SETTING_DEFINITIONS = [
{ key: 'app.name', type: 'string', defaultValue: 'Pulse Signage' },
+1 -1
View File
@@ -48,7 +48,7 @@ const SCREEN_CONTROL_COMMAND_LABELS = Object.freeze({
moveclient: 'Move client',
setclientname: 'Rename client'
});
const { fetchAppSettings } = require('./app-settings');
const { fetchAppSettings } = require('#src/data/app-settings');
function formatUserAgentLabel(userAgent) {
const value = String(userAgent || '').trim();
+1 -1
View File
@@ -1,6 +1,6 @@
// Canvas size data access and pagination helpers.
const { fetchPagedRows } = require('./utils');
const { fetchPagedRows } = require('#src/data/utils');
const MAX_CANVAS_SIZE_DIMENSION = 16384;
async function fetchCanvasSizesData(pool) {
+14 -14
View File
@@ -1,19 +1,19 @@
// Aggregated exports for the shared data access layer.
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('./timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('./weather');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('./screens');
const { fetchPlayerRegistrations } = require('./player-registry');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides');
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('#src/data/admin');
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('#src/data/announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('#src/data/announcement-icons');
const { fetchPlaylistById } = require('#src/data/playlists');
const { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = require('#src/data/timetables');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('#src/data/api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('#src/data/rss-feeds');
const { fetchWeatherLocationsData, fetchWeatherLocationsPage, fetchWeatherLocationById, fetchWeatherLocationSuggestions, fetchWeatherLocationForecast, buildWeatherLocationPayload } = require('#src/data/weather');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchPlayerPublicBaseUrl, fetchScreenPlayerRecord, fetchPlayerRecordByIdentifier } = require('#src/data/screens');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('#src/data/templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('#src/data/canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('#src/data/slides');
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('#src/data/utils');
module.exports = {
slugify,
+33
View File
@@ -1,4 +1,6 @@
// Persistent player registration and heartbeat helpers shared by the web app and bridge.
// These utilities intentionally keep URL normalization and heartbeat staleness rules in one place
// so the web routing logic and media sync tasks all make the same decision about a live player.
function normalizeDeviceId(value) {
return String(value || '')
@@ -11,6 +13,34 @@ function normalizeBaseUrl(value) {
return String(value || '').trim().replace(/\/$/, '');
}
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 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 normalizeIdentifier(value) {
return String(value || '').trim().slice(0, 255);
}
@@ -143,6 +173,9 @@ async function recordPlayerHeartbeat(pool, options) {
}
module.exports = {
normalizeBaseUrl: normalizeBaseUrl,
isLocalLikeBaseUrl: isLocalLikeBaseUrl,
isRecentPlayerRegistration: isRecentPlayerRegistration,
normalizeDeviceId: normalizeDeviceId,
normalizeIdentifier: normalizeIdentifier,
getConfiguredPlayerIdentifier: getConfiguredPlayerIdentifier,
+1 -1
View File
@@ -2,7 +2,7 @@
const http = require('http');
const https = require('https');
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const NAME_MAX_LENGTH = 255;
const URL_MAX_LENGTH = 1024;
+61 -4
View File
@@ -1,10 +1,10 @@
// Slide data access helpers, including rich-text normalization and payload building.
const { fetchTemplateById } = require('./templates');
const { parseJsonSafe, validateMaxLength } = require('./utils');
const { fetchTemplateById } = require('#src/data/templates');
const { parseJsonSafe, validateMaxLength } = require('#src/data/utils');
const TITLE_MAX_LENGTH = 255;
const { buildQrCodeContent } = require('./qr-code');
const { buildQrCodeContent } = require('#src/data/qr-code');
const DEFAULT_FONT_SIZE = 32;
@@ -474,7 +474,64 @@ async function buildTemplateContent(pool, template, body, filesByField, existing
font_size: style.font_size,
font_color: style.font_color
};
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather'].includes(String(region.region_type || '').trim())) {
} else if (region.region_type === 'chart') {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
let manualRows = [];
let datasets = [];
try {
const parsedRows = JSON.parse(String(body[`region_chart_manual_rows_${region.id}`] || '[]'));
manualRows = Array.isArray(parsedRows) ? parsedRows.slice(0, 500) : [];
} catch (_error) {
manualRows = Array.isArray(current.manual_rows) ? current.manual_rows : [];
}
try {
const parsedDatasets = JSON.parse(String(body[`region_chart_datasets_${region.id}`] || '[]'));
datasets = Array.isArray(parsedDatasets) ? parsedDatasets.slice(0, 12) : [];
} catch (_error) {
datasets = Array.isArray(current.datasets) ? current.datasets : [];
}
const colors = String(body[`region_chart_colors_${region.id}`] === undefined ? (Array.isArray(current.colors) ? current.colors.join(',') : '') : body[`region_chart_colors_${region.id}`] || '')
.split(',')
.map((color) => String(color || '').trim())
.filter((color) => /^#[0-9a-fA-F]{3,8}$/.test(color))
.slice(0, 24);
const chartSourceKind = String(body[`region_chart_source_kind_${region.id}`] || current.source_kind || 'manual') === 'api' ? 'api' : 'manual';
const chartOptions = {
legend: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic')) ? String(body[`region_chart_legend_${region.id}`] || current.chart_options?.legend || 'automatic') : 'automatic',
begin_at_zero: body[`region_chart_begin_at_zero_control_${region.id}`] === undefined ? current.chart_options?.begin_at_zero !== false : body[`region_chart_begin_at_zero_${region.id}`] === 'on',
y_axis_min: String(body[`region_chart_y_axis_min_${region.id}`] === undefined ? current.chart_options?.y_axis_min || '' : body[`region_chart_y_axis_min_${region.id}`] || '').trim(),
y_axis_max: String(body[`region_chart_y_axis_max_${region.id}`] === undefined ? current.chart_options?.y_axis_max || '' : body[`region_chart_y_axis_max_${region.id}`] || '').trim(),
line_points: ['automatic', 'show', 'hide'].includes(String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic')) ? String(body[`region_chart_line_points_${region.id}`] || current.chart_options?.line_points || 'automatic') : 'automatic',
line_fill: body[`region_chart_line_fill_${region.id}`] === 'on',
bar_orientation: String(body[`region_chart_bar_orientation_${region.id}`] || current.chart_options?.bar_orientation || 'vertical') === 'horizontal' ? 'horizontal' : 'vertical',
value_format: ['number', 'percent', 'currency'].includes(String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number')) ? String(body[`region_chart_value_format_${region.id}`] || current.chart_options?.value_format || 'number') : 'number',
decimal_places: Math.max(0, Math.min(4, Number(body[`region_chart_decimal_places_${region.id}`] === undefined ? current.chart_options?.decimal_places || 0 : body[`region_chart_decimal_places_${region.id}`] || 0) || 0)),
text_size: Math.max(8, Math.min(48, Number(body[`region_chart_text_size_${region.id}`] === undefined ? current.chart_options?.text_size || 12 : body[`region_chart_text_size_${region.id}`] || 12) || 12)),
grid_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color || '')) ? String(body[`region_chart_grid_color_${region.id}`] || current.chart_options?.grid_color) : '#000000',
text_color: /^#[0-9a-fA-F]{6}$/.test(String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color || '')) ? String(body[`region_chart_text_color_${region.id}`] || current.chart_options?.text_color) : '#000000'
};
const sourceOptions = {
sort: ['none', 'reverse-original', 'value-ascending', 'value-descending'].includes(String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none')) ? String(body[`region_chart_source_sort_${region.id}`] || current.source_options?.sort || 'none') : 'none',
limit: Math.max(0, Math.min(500, Number(body[`region_chart_source_limit_${region.id}`] === undefined ? current.source_options?.limit || 0 : body[`region_chart_source_limit_${region.id}`] || 0) || 0)),
missing: ['skip', 'zero', 'gap'].includes(String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap')) ? String(body[`region_chart_source_missing_${region.id}`] || current.source_options?.missing || 'gap') : 'gap'
};
content[region.region_key] = {
type: 'chart',
chart_type: ['line', 'bar', 'doughnut', 'pie'].includes(String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar')) ? String(body[`region_chart_type_${region.id}`] || current.chart_type || 'bar') : 'bar',
source_kind: chartSourceKind,
source_id: chartSourceKind === 'api' ? (body[`region_chart_source_id_${region.id}`] === undefined ? (current.source_id || null) : (body[`region_chart_source_id_${region.id}`] === '' ? null : Number(body[`region_chart_source_id_${region.id}`]))) : null,
items_path: String(body[`region_chart_items_path_${region.id}`] === undefined ? current.items_path || '' : body[`region_chart_items_path_${region.id}`] || '').trim(),
label_path: String(body[`region_chart_label_path_${region.id}`] === undefined ? current.label_path || '' : body[`region_chart_label_path_${region.id}`] || '').trim(),
value_path: String(body[`region_chart_value_path_${region.id}`] === undefined ? current.value_path || '' : body[`region_chart_value_path_${region.id}`] || '').trim(),
dataset_label: String(body[`region_chart_dataset_label_${region.id}`] === undefined ? current.dataset_label || 'Value' : body[`region_chart_dataset_label_${region.id}`] || 'Value').trim() || 'Value',
color: /^#[0-9a-fA-F]{3,8}$/.test(String(body[`region_chart_color_${region.id}`] || current.color || '')) ? String(body[`region_chart_color_${region.id}`]) : (current.color || '#36a269'),
datasets,
colors,
manual_rows: manualRows,
chart_options: chartOptions,
source_options: sourceOptions
};
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather', 'chart'].includes(String(region.region_type || '').trim())) {
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
const suffix = '_' + region.id;
const generic = {};
+4 -3
View File
@@ -1,7 +1,7 @@
// Template data access helpers and region normalization logic.
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
const animationPresets = require('../web/public/js/templates/animation-presets');
const { parseJsonSafe, readFormArray, validateMaxLength } = require('#src/data/utils');
const animationPresets = require('#src/web/public/js/templates/animation-presets');
const TEMPLATE_NAME_MAX_LENGTH = 255;
const REGION_NAME_MAX_LENGTH = 255;
@@ -49,7 +49,8 @@ function normalizeBackgroundGradient(value) {
function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim();
return rawType || 'text';
const normalizedType = rawType.toLowerCase();
return ['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api', 'weather', 'timetable', 'time-date', 'chart'].includes(normalizedType) ? rawType : 'text';
}
function normalizeTemplateRegionLockRatio(value) {
+1 -1
View File
@@ -1,6 +1,6 @@
// Timetable group and entry data access helpers.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const NAME_MAX_LENGTH = 255;
const DESCRIPTION_MAX_LENGTH = 255;
+2 -2
View File
@@ -1,7 +1,7 @@
// Weather location data access and form normalization.
const { fetchPagedRows, validateMaxLength } = require('./utils');
const { fetchAppSettings } = require('./app-settings');
const { fetchPagedRows, validateMaxLength } = require('#src/data/utils');
const { fetchAppSettings } = require('#src/data/app-settings');
const NAME_MAX_LENGTH = 255;
const LOCATION_MAX_LENGTH = 255;
+1 -1
View File
@@ -1,7 +1,7 @@
// Schema initialization, migration execution, and database bootstrap helpers.
const { version: appVersion } = require('#root/package.json');
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('./migrations');
const { compareVersions, detectSchemaVersion, getPendingMigrations, recordSchemaVersion, runMigrations } = require('#src/db/migrations');
// Snapshot only: keep this file aligned with the current schema state.
async function ensureSchema(pool, options) {
+191
View File
@@ -0,0 +1,191 @@
'use strict';
// This bridge state owns the in-memory socket and screen snapshot lifecycle so the
// HTTP/WebSocket bootstrap stays focused on routing instead of mutable state.
const { WebSocket } = require('ws');
function createPlayerBridgeState() {
const playerSockets = new Map();
const screenSnapshotCache = new Map();
const screenSnapshotSourcesBySlug = new Map();
const screenSnapshotSubscribersBySlug = new Map();
const screenPlayerDeviceIds = new Map();
function getScreenSnapshotSourceBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!screenSnapshotSourcesBySlug.has(key)) {
screenSnapshotSourcesBySlug.set(key, new Map());
}
return screenSnapshotSourcesBySlug.get(key);
}
function getScreenSnapshotSubscriberBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!screenSnapshotSubscribersBySlug.has(key)) {
screenSnapshotSubscribersBySlug.set(key, new Set());
}
return screenSnapshotSubscribersBySlug.get(key);
}
function storeScreenSnapshot(slug, connections, deviceIds) {
const key = String(slug || '').trim();
const normalizedConnections = [];
const connectionIndexes = new Map();
(Array.isArray(connections) ? connections : []).forEach(function (connection) {
const identity = connection && typeof connection === 'object'
? [String(connection.deviceId || '').trim(), String(connection.clientId || '').trim()].join('|')
: '';
if (!identity || !connectionIndexes.has(identity)) {
if (identity) {
connectionIndexes.set(identity, normalizedConnections.length);
}
normalizedConnections.push(connection);
return;
}
normalizedConnections[connectionIndexes.get(identity)] = connection;
});
const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.filter(Boolean).map(function (value) {
return String(value || '').trim();
}).filter(Boolean) : [];
screenSnapshotCache.set(key, {
slug: key,
count: normalizedConnections.length,
connections: normalizedConnections
});
screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds)));
}
function buildMergedScreenSnapshot(slug) {
const key = String(slug || '').trim();
const sourceBucket = screenSnapshotSourcesBySlug.get(key);
const connections = [];
const deviceIds = [];
if (sourceBucket && typeof sourceBucket.forEach === 'function') {
sourceBucket.forEach(function (payload) {
if (payload && Array.isArray(payload.connections)) {
connections.push.apply(connections, payload.connections);
}
});
sourceBucket.forEach(function (_payload, sourceKey) {
deviceIds.push(sourceKey);
});
}
return {
slug: key,
count: connections.length,
connections: connections,
deviceIds: deviceIds
};
}
function broadcastScreenSnapshot(slug) {
const key = String(slug || '').trim();
const snapshot = buildMergedScreenSnapshot(key);
storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds);
const bucket = screenSnapshotSubscribersBySlug.get(key);
if (!bucket || !bucket.size) {
return;
}
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshot.connections,
sentAt: new Date().toISOString()
});
bucket.forEach(function (socket) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
});
}
function setScreenSnapshotSource(slug, sourceKey, connections) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
if (!key || !normalizedSourceKey) {
return;
}
const bucket = getScreenSnapshotSourceBucket(key);
if (!bucket) {
return;
}
bucket.set(normalizedSourceKey, {
slug: key,
connections: Array.isArray(connections) ? connections : []
});
broadcastScreenSnapshot(key);
}
function clearScreenSnapshotSource(slug, sourceKey) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
const bucket = screenSnapshotSourcesBySlug.get(key);
if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) {
return;
}
bucket.delete(normalizedSourceKey);
if (!bucket.size) {
screenSnapshotSourcesBySlug.delete(key);
}
broadcastScreenSnapshot(key);
}
function clearPlayerSnapshotSources(sourceKey) {
const normalizedSourceKey = String(sourceKey || '').trim();
if (!normalizedSourceKey) {
return;
}
Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) {
clearScreenSnapshotSource(slug, normalizedSourceKey);
});
}
function getScreenSnapshotCount(slug) {
const snapshot = screenSnapshotCache.get(String(slug || '').trim()) || { count: 0 };
return Number(snapshot.count || 0);
}
return {
playerSockets: playerSockets,
screenSnapshotCache: screenSnapshotCache,
screenSnapshotSourcesBySlug: screenSnapshotSourcesBySlug,
screenSnapshotSubscribersBySlug: screenSnapshotSubscribersBySlug,
screenPlayerDeviceIds: screenPlayerDeviceIds,
getScreenSnapshotSourceBucket: getScreenSnapshotSourceBucket,
getScreenSnapshotSubscriberBucket: getScreenSnapshotSubscriberBucket,
storeScreenSnapshot: storeScreenSnapshot,
buildMergedScreenSnapshot: buildMergedScreenSnapshot,
broadcastScreenSnapshot: broadcastScreenSnapshot,
setScreenSnapshotSource: setScreenSnapshotSource,
clearScreenSnapshotSource: clearScreenSnapshotSource,
clearPlayerSnapshotSources: clearPlayerSnapshotSources,
getScreenSnapshotCount: getScreenSnapshotCount
};
}
module.exports = {
createPlayerBridgeState: createPlayerBridgeState
};
+21 -161
View File
@@ -6,14 +6,14 @@ const fs = require('fs');
const http = require('http');
const path = require('path');
const { WebSocketServer, WebSocket } = require('ws');
const common = require('../common');
const { verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const common = require('#src/common');
const { verifyRequestAuth, createRequestAuthHeaders, verifyPageAuthToken } = require('#src/request-auth');
const { normalizeDeviceId, upsertPlayerRegistration, recordPlayerHeartbeat } = require('#src/data/player-registry');
const { createPlayerPlaylistService } = require('../player/playlist');
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('../player/onboarding');
const { createStyledQrCodeSvg } = require('../data/qr-code');
const { verifyPageAuthToken } = require('#src/request-auth');
const { collectLocalControlUsers } = require('../web/lib/local-control-users');
const { createPlayerBridgeState } = require('#src/player-bridge/connection-state');
const { createPlayerPlaylistService } = require('#src/player/playlist');
const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerPublicBaseUrl } = require('#src/player/onboarding/index');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { collectLocalControlUsers } = require('#src/web/lib/local-control-users');
function createThinClientConfig() {
@@ -187,6 +187,7 @@ async function start() {
const server = http.createServer(app);
const pool = common.createPool();
const config = createThinClientConfig();
const bridgeState = createPlayerBridgeState();
const localPlayerInternalUrl = String(process.env.LOCAL_PLAYER_INTERNAL_URL || process.env.PLAYER_INTERNAL_URL || '').trim().replace(/\/$/, '');
const playerPlaylistService = createPlayerPlaylistService({
pool: pool,
@@ -197,133 +198,21 @@ async function start() {
app.use(express.json());
const playersWs = new WebSocketServer({ noServer: true });
const screenSnapshotsWs = new WebSocketServer({ noServer: true });
const playerSockets = new Map();
const screenSnapshotCache = new Map();
const screenSnapshotSourcesBySlug = new Map();
const screenSnapshotSubscribersBySlug = new Map();
const screenPlayerDeviceIds = new Map();
const playerSockets = bridgeState.playerSockets;
const screenSnapshotCache = bridgeState.screenSnapshotCache;
const screenSnapshotSourcesBySlug = bridgeState.screenSnapshotSourcesBySlug;
const screenSnapshotSubscribersBySlug = bridgeState.screenSnapshotSubscribersBySlug;
const screenPlayerDeviceIds = bridgeState.screenPlayerDeviceIds;
const pendingPlayerCommands = new Map();
function getScreenSnapshotSourceBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!screenSnapshotSourcesBySlug.has(key)) {
screenSnapshotSourcesBySlug.set(key, new Map());
}
return screenSnapshotSourcesBySlug.get(key);
}
function getScreenSnapshotSubscriberBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
return null;
}
if (!screenSnapshotSubscribersBySlug.has(key)) {
screenSnapshotSubscribersBySlug.set(key, new Set());
}
return screenSnapshotSubscribersBySlug.get(key);
}
function buildMergedScreenSnapshot(slug) {
const key = String(slug || '').trim();
const sourceBucket = screenSnapshotSourcesBySlug.get(key);
const connections = [];
const deviceIds = [];
if (sourceBucket && typeof sourceBucket.forEach === 'function') {
sourceBucket.forEach(function (payload) {
if (payload && Array.isArray(payload.connections)) {
connections.push.apply(connections, payload.connections);
}
});
sourceBucket.forEach(function (_payload, sourceKey) {
deviceIds.push(sourceKey);
});
}
return {
slug: key,
count: connections.length,
connections: connections,
deviceIds: deviceIds
};
}
function broadcastScreenSnapshot(slug) {
const key = String(slug || '').trim();
const snapshot = buildMergedScreenSnapshot(key);
storeScreenSnapshot(key, snapshot.connections, snapshot.deviceIds);
const bucket = screenSnapshotSubscribersBySlug.get(key);
if (!bucket || !bucket.size) {
return;
}
const payload = JSON.stringify({
type: 'snapshot',
slug: key,
connections: snapshot.connections,
sentAt: new Date().toISOString()
});
bucket.forEach(function (socket) {
if (socket && socket.readyState === WebSocket.OPEN) {
socket.send(payload);
}
});
}
function setScreenSnapshotSource(slug, sourceKey, connections) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
if (!key || !normalizedSourceKey) {
return;
}
const bucket = getScreenSnapshotSourceBucket(key);
if (!bucket) {
return;
}
bucket.set(normalizedSourceKey, {
slug: key,
connections: Array.isArray(connections) ? connections : []
});
broadcastScreenSnapshot(key);
}
function clearScreenSnapshotSource(slug, sourceKey) {
const key = String(slug || '').trim();
const normalizedSourceKey = String(sourceKey || '').trim();
const bucket = screenSnapshotSourcesBySlug.get(key);
if (!bucket || !normalizedSourceKey || !bucket.has(normalizedSourceKey)) {
return;
}
bucket.delete(normalizedSourceKey);
if (!bucket.size) {
screenSnapshotSourcesBySlug.delete(key);
}
broadcastScreenSnapshot(key);
}
function clearPlayerSnapshotSources(sourceKey) {
const normalizedSourceKey = String(sourceKey || '').trim();
if (!normalizedSourceKey) {
return;
}
Array.from(screenSnapshotSourcesBySlug.keys()).forEach(function (slug) {
clearScreenSnapshotSource(slug, normalizedSourceKey);
});
}
const getScreenSnapshotSourceBucket = bridgeState.getScreenSnapshotSourceBucket;
const getScreenSnapshotSubscriberBucket = bridgeState.getScreenSnapshotSubscriberBucket;
const buildMergedScreenSnapshot = bridgeState.buildMergedScreenSnapshot;
const broadcastScreenSnapshot = bridgeState.broadcastScreenSnapshot;
const setScreenSnapshotSource = bridgeState.setScreenSnapshotSource;
const clearScreenSnapshotSource = bridgeState.clearScreenSnapshotSource;
const clearPlayerSnapshotSources = bridgeState.clearPlayerSnapshotSources;
const storeScreenSnapshot = bridgeState.storeScreenSnapshot;
async function fetchPlayerSnapshotRegistrations() {
if (typeof common.fetchPlayerRegistrations === 'function') {
@@ -471,35 +360,6 @@ async function start() {
return Array.isArray(deviceIds) ? deviceIds.slice() : [];
}
function storeScreenSnapshot(slug, connections, deviceIds) {
const key = String(slug || '').trim();
const normalizedConnections = [];
const connectionIndexes = new Map();
(Array.isArray(connections) ? connections : []).forEach(function (connection) {
const identity = connection && typeof connection === 'object'
? [String(connection.deviceId || '').trim(), String(connection.clientId || '').trim()].join('|')
: '';
if (!identity || !connectionIndexes.has(identity)) {
if (identity) {
connectionIndexes.set(identity, normalizedConnections.length);
}
normalizedConnections.push(connection);
return;
}
normalizedConnections[connectionIndexes.get(identity)] = connection;
});
const normalizedDeviceIds = Array.isArray(deviceIds) ? deviceIds.map(function (value) {
return normalizeDeviceId(value);
}).filter(Boolean) : [];
screenSnapshotCache.set(key, {
slug: key,
count: normalizedConnections.length,
connections: normalizedConnections
});
screenPlayerDeviceIds.set(key, Array.from(new Set(normalizedDeviceIds)));
}
app.get('/health', function (_req, res) {
res.json({ ok: true, service: 'player-bridge' });
});
+10 -10
View File
@@ -5,19 +5,19 @@ const fs = require('fs');
const http = require('http');
const path = require('path');
const { WebSocket } = require('ws');
const common = require('./common');
const { createPlayerRuntime } = require('./player/runtime');
const { createPlayerPlaylistService } = require('./player/playlist');
const { createRtmpStreamService } = require('./player/modules/rtmp-streams');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('./player/onboarding');
const { createOnboardingStore } = require('./player/onboarding/store');
const { registerPlayerRoutes } = require('./player/routes');
const { getPlayerRuntimeScripts } = require('./player/render-helpers');
const common = require('#src/common');
const { createPlayerRuntime } = require('#src/player/runtime');
const { createPlayerPlaylistService } = require('#src/player/playlist');
const { createRtmpStreamService } = require('#src/player/modules/rtmp-streams');
const { normalizeDeviceId, registerPlayerOnboardingRoutes, commitDeviceBinding } = require('#src/player/onboarding/index');
const { createOnboardingStore } = require('#src/player/onboarding/store');
const { registerPlayerRoutes } = require('#src/player/routes');
const { getPlayerRuntimeScripts } = require('#src/player/render-helpers');
const { ensureFontLibrary } = require('#src/web/lib/media/font-library');
const { createRequestAuthHeaders } = require('#src/request-auth');
const { withClientNameReservation } = require('#src/data/client-name-check');
const { getConfiguredPlayerIdentifier, recordPlayerHeartbeat } = require('#src/data/player-registry');
const { createLocalControlService } = require('./player/local-control');
const { createLocalControlService } = require('#src/player/local-control');
// Player runtime, media API, and websocket wiring.
@@ -627,7 +627,7 @@ async function start() {
await syncDatabaseState();
if (PLAYER_DEVICE_ID && !isRemotePlayer) {
const { upsertPlayerRegistration } = require('./player/onboarding');
const { upsertPlayerRegistration } = require('#src/player/onboarding/index');
await upsertPlayerRegistration(pool, PLAYER_DEVICE_ID, getPlayerPublicBaseUrl(), PLAYER_INTERNAL_URL).catch(function (error) {
console.error(error);
});
+1 -1
View File
@@ -6,7 +6,7 @@ const { findAvailableClientName, withClientNameReservation } = require('#src/dat
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
const { isTransientDbError } = require('./store');
const { isTransientDbError } = require('#src/player/onboarding/store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
+15 -2
View File
@@ -185,6 +185,13 @@
}, 15000);
}
function stopAnnouncementPolling() {
if (announcementPollTimer) {
window.clearInterval(announcementPollTimer);
announcementPollTimer = null;
}
}
function scheduleAnnouncementReconnect() {
if (announcementReconnectTimer) {
return;
@@ -222,6 +229,7 @@
announcementSocket = socket;
socket.onopen = function () {
stopAnnouncementPolling();
void refreshAnnouncementOverlay();
};
@@ -231,6 +239,7 @@
socket.onclose = function () {
announcementSocket = null;
scheduleAnnouncementPolling();
scheduleAnnouncementReconnect();
};
@@ -247,14 +256,18 @@
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', function () {
void refreshAnnouncementOverlay();
scheduleAnnouncementPolling();
connectAnnouncementSocket();
if (!window.WebSocket) {
void refreshAnnouncementOverlay();
}
}, { once: true });
} else {
void refreshAnnouncementOverlay();
scheduleAnnouncementPolling();
connectAnnouncementSocket();
if (!window.WebSocket) {
void refreshAnnouncementOverlay();
}
}
window.addEventListener('focus', function () {
+6 -2
View File
@@ -25,6 +25,8 @@
let slideRenderRequestId = 0;
let preloadContainer = null;
let preloadSignature = '';
let mediaPreloadSignature = '';
let mediaPreloadElements = [];
let commandSocket = null;
let commandReconnectTimer = null;
let commandStateTimer = null;
@@ -47,6 +49,7 @@
const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug);
const commandClientStorageKey = 'pulse-signage-player-client-id';
const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug;
const playbackStateStorageKey = 'pulse-signage-player-playback-state:' + slug;
const initialPlaylistSnapshot = loadPlaylistSnapshot();
let offlineBanner = null;
let offlineBannerVisible = false;
@@ -104,6 +107,7 @@
}));
}
startPlayerWatchdog();
window.addEventListener('error', function (event) {
logDebug('Player script error.', String(event.message || event.error || 'unknown error'), 'error');
});
@@ -134,7 +138,6 @@
window.addEventListener('online', function () {
syncOfflineBanner();
markRefreshHealthy();
refresh();
});
@@ -147,7 +150,6 @@
syncOfflineBanner();
syncScreenWakeLock();
if (document.visibilityState === 'visible') {
markRefreshHealthy();
refresh();
}
});
@@ -175,6 +177,7 @@
skipUnavailableRtmp: currentPlaylistSkipUnavailableRtmp,
etag: currentPlaylistEtag
});
restorePlaybackState();
syncWebpagePreloads(getActiveSlidesFrom(slides), index);
if (typeof syncRtmpWarmups === 'function') {
syncRtmpWarmups(getActiveSlidesFrom(slides), index);
@@ -185,6 +188,7 @@
} else {
if (initialPlaylistSnapshot && applyPlaylistSnapshot(initialPlaylistSnapshot)) {
currentPlaylistSkipUnavailableRtmp = Boolean(initialPlaylistSnapshot.skipUnavailableRtmp);
restorePlaybackState();
showCurrent();
}
syncBlackoutState();
+45 -2
View File
@@ -4,7 +4,9 @@ const crypto = require('crypto');
const { convertWeatherSnapshot } = require('#src/data/weather-units');
const fs = require('fs');
const path = require('path');
const { createStyledQrCodeDataUrl } = require('../data/qr-code');
const { createStyledQrCodeDataUrl } = require('#src/data/qr-code');
const IMAGE_VARIANT_DIMENSIONS = [2048, 4096, 8192, 16384];
function createPlayerPlaylistService(options) {
const pool = options && options.pool ? options.pool : null;
@@ -64,6 +66,28 @@ function createPlayerPlaylistService(options) {
return createStyledQrCodeDataUrl(value);
}
async function getImageVariants(mediaPath) {
const normalizedPath = String(mediaPath || '').trim().split(/[?#]/)[0];
if (!mediaDir || !/^\/media\/uploads\/[^/]+\.(?:jpe?g|png|webp)$/i.test(normalizedPath)) {
return [];
}
const fileName = path.basename(normalizedPath);
const extension = path.extname(fileName);
const baseName = path.basename(fileName, extension);
const variants = [];
for (const width of IMAGE_VARIANT_DIMENSIONS) {
const variantName = baseName + '.variant-' + width + extension;
const variantPath = path.join(mediaDir, 'uploads', variantName);
try {
await fs.promises.access(variantPath, fs.constants.F_OK);
variants.push({ width: width, url: '/media/uploads/' + variantName });
} catch (_error) {
}
}
return variants;
}
function getPlaceholderImageExpressions(value, output) {
const expressions = output || [];
const source = String(value || '');
@@ -281,6 +305,25 @@ function createPlayerPlaylistService(options) {
}
});
await Promise.all(Object.keys(content).map(async function (key) {
const region = content[key];
if (!region || typeof region !== 'object' || !['image', 'video'].includes(String(region.type || '').trim().toLowerCase())) {
return;
}
const variants = await getImageVariants(region.value);
if (variants.length) {
region.media_variants = variants;
}
}));
const template = slide.template_id ? templatesById[slide.template_id] || null : null;
if (template && template.background_image_path && !template.background_image_variants) {
const variants = await getImageVariants(template.background_image_path);
if (variants.length) {
template.background_image_variants = variants;
}
}
Object.keys(content).forEach(function (key) {
const region = content[key];
if (!region || typeof region !== 'object' || String(region.type || '').trim().toLowerCase() !== 'qr-code') {
@@ -310,7 +353,7 @@ function createPlayerPlaylistService(options) {
disable_audio: disableAudio,
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
template: template,
content: content
};
}));
@@ -2,15 +2,7 @@
var ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
var ANNOUNCEMENT_ICONS = Array.isArray(window.pulseAnnouncementIconKeys) ? window.pulseAnnouncementIconKeys : [];
var DEFAULT_ANNOUNCEMENT_ICON = String(window.pulseAnnouncementDefaultIconKey || ANNOUNCEMENT_ICONS[0] || '').trim().toLowerCase();
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
var escapeHtml = window.pulsePlayerRenderingShared.escapeHtml;
function normalizeAnnouncementType(value) {
var normalized = String(value || '').trim().toLowerCase();
+23 -143
View File
@@ -7,6 +7,7 @@ function getCurrentViewport() {
}
var commandHeartbeatTimer = null;
var commandReconnectDelayMs = 0;
// Command websocket and player-state helpers.
// Send the current playback state to the command websocket.
@@ -61,137 +62,6 @@ function scheduleViewportRenderUpdate() {
}, 150);
}
function getPlayerRegionModules() {
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
return [];
}
return window.pulsePlayerRegionTypes.list();
}
function runRegionLifecycle(root, lifecycleName) {
if (!root) {
return;
}
getPlayerRegionModules().forEach(function (entry) {
var module = entry && entry.definition ? entry.definition : null;
if (!module || typeof module[lifecycleName] !== 'function') {
return;
}
try {
module[lifecycleName](root);
} catch (_error) {
// Keep slide rendering resilient if a region lifecycle hook fails.
}
});
}
function destroyRegionInstances(root) {
runRegionLifecycle(root, 'destroyRegion');
}
function initializeRegionInstances(root) {
runRegionLifecycle(root, 'initRegion');
}
// Swap slide markup with optional fade animation.
function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
clearSlideTransitionTimer();
destroyRegionInstances(app);
if (typeof destroySlideMedia === 'function') {
destroySlideMedia(app);
}
function schedulePostRenderSetup(root, delayMs) {
if (!root) {
return;
}
if (root.isConnected === false) {
return;
}
initializeRegionInstances(root);
if (typeof initializeSlideMedia === 'function') {
initializeSlideMedia(root, delayMs);
}
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(root, 'intro');
}
}
if (!shouldFade) {
app.innerHTML = markup;
schedulePostRenderSetup(app, 0);
return app.firstElementChild;
}
var topLevelChildren = Array.prototype.slice.call(app.children || []);
var existingShells = topLevelChildren.filter(function (child) {
return child && child.classList && child.classList.contains('slide-shell');
});
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
if (existingShells.length > 1) {
existingShells.slice(0, -1).forEach(function (shell) {
if (shell && shell.parentNode) {
shell.parentNode.removeChild(shell);
}
});
}
var nextShell = document.createElement('div');
nextShell.className = 'slide-shell slide-shell-entering';
nextShell.style.zIndex = '0';
nextShell.style.opacity = '0';
nextShell.innerHTML = markup;
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
app.innerHTML = '';
nextShell.style.opacity = '1';
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
app.appendChild(nextShell);
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
return nextShell;
}
if (!previousShell.classList.contains('slide-shell')) {
previousShell.classList.add('slide-shell');
}
previousShell.classList.remove('is-visible');
previousShell.classList.add('is-exiting');
previousShell.style.zIndex = '1';
previousShell.style.opacity = '1';
app.appendChild(nextShell);
window.requestAnimationFrame(function () {
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
});
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell);
}
if (nextShell) {
nextShell.style.zIndex = '1';
nextShell.style.opacity = '1';
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
}
slideTransitionTimer = null;
}, slideFadeLengthMs);
return nextShell;
}
// Mirror blackout state onto the document body.
function syncBlackoutState() {
document.body.classList.toggle('screen-blackout', isBlackout);
@@ -207,11 +77,13 @@ function setPaused(nextPaused) {
pausedRemainingMs = slideExpiresAt ? Math.max(0, slideExpiresAt - Date.now()) : null;
isPaused = true;
clearSlideTimer();
savePlaybackState();
sendCommandState(lastRenderedSlide);
return;
}
isPaused = false;
savePlaybackState();
sendCommandState(lastRenderedSlide);
if (!slides.length || !lastRenderedSlide) {
return;
@@ -230,6 +102,7 @@ function setBlackout(nextBlackout) {
}
isBlackout = normalized;
syncBlackoutState();
savePlaybackState();
sendCommandState(lastRenderedSlide);
}
@@ -357,10 +230,6 @@ function handleCommandMessage(rawMessage) {
} catch (_error) {
return;
}
if (payload && payload.type === 'client-id-conflict') {
handleClientIdConflict();
return;
}
if (payload && payload.type === 'client-name-updated') {
if (payload.clientName) {
applyOnboardingClientName(payload.clientName, commandSocket);
@@ -437,23 +306,31 @@ function handleCommandMessage(rawMessage) {
}
}
function handleClientIdConflict() {
var replacementClientId = regenerateCommandClientId();
var onboardingUrl = new URL('/', window.location.origin);
window.location.replace(onboardingUrl.toString());
}
// Retry the command websocket after a disconnect.
function scheduleCommandReconnect() {
if (commandReconnectTimer) {
return;
}
var baseDelayMs = commandReconnectDelayMs
? Math.min(commandReconnectDelayMs * 2, 30000)
: 1000;
commandReconnectDelayMs = baseDelayMs;
var jitteredDelayMs = Math.round(baseDelayMs * (0.75 + Math.random() * 0.5));
commandReconnectTimer = window.setTimeout(function () {
commandReconnectTimer = null;
connectCommandSocket();
}, 5000);
}, jitteredDelayMs);
}
window.addEventListener('online', function () {
if (commandReconnectTimer) {
window.clearTimeout(commandReconnectTimer);
commandReconnectTimer = null;
}
commandReconnectDelayMs = 0;
connectCommandSocket();
});
// Open and wire the command websocket connection.
function connectCommandSocket() {
if (!window.WebSocket) {
@@ -466,6 +343,7 @@ function connectCommandSocket() {
commandSocket = socket;
socket.onopen = function () {
commandReconnectDelayMs = 0;
if (commandHeartbeatTimer) {
window.clearInterval(commandHeartbeatTimer);
}
@@ -495,7 +373,9 @@ function connectCommandSocket() {
}
commandSocket = null;
if (event && event.code === 4009) {
handleClientIdConflict();
regenerateCommandClientId();
commandReconnectDelayMs = 0;
scheduleCommandReconnect();
return;
}
scheduleCommandReconnect();
+67 -8
View File
@@ -1,5 +1,15 @@
// Slide media startup and teardown helpers.
var renderedVideoPlaybackCleanup = new WeakMap();
function cancelRenderedVideoPlayback(video) {
var cleanup = renderedVideoPlaybackCleanup.get(video);
if (cleanup) {
cleanup();
renderedVideoPlaybackCleanup.delete(video);
}
}
function initializeRenderedVideoPlayback(root, delayMs) {
if (!root) {
return;
@@ -12,14 +22,37 @@ function initializeRenderedVideoPlayback(root, delayMs) {
return;
}
cancelRenderedVideoPlayback(video);
var playbackScheduled = false;
var playbackCancelled = false;
var playbackTimer = null;
function removeReadinessListeners() {
video.removeEventListener('canplay', schedulePlaybackStart);
video.removeEventListener('loadedmetadata', onLoadedMetadata);
}
// A cancelled callback can already be queued for dispatch, so it also checks this flag.
renderedVideoPlaybackCleanup.set(video, function () {
playbackCancelled = true;
if (playbackTimer !== null) {
window.clearTimeout(playbackTimer);
playbackTimer = null;
}
removeReadinessListeners();
});
// Script-controlled startup aligns playback with the incoming slide's fade timing.
video.autoplay = false;
video.loop = !(video.dataset && video.dataset.loop === '0');
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
video.playsInline = true;
function startPlayback() {
playbackTimer = null;
if (playbackCancelled || video.isConnected === false || root.isConnected === false) {
return;
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
@@ -29,28 +62,33 @@ function initializeRenderedVideoPlayback(root, delayMs) {
}
function schedulePlaybackStart() {
if (playbackScheduled) {
// Metadata and canplay can both fire; schedule only one start for this video.
if (playbackCancelled || playbackScheduled) {
return;
}
playbackScheduled = true;
removeReadinessListeners();
if (startDelayMs > 0) {
window.setTimeout(startPlayback, startDelayMs);
playbackTimer = window.setTimeout(startPlayback, startDelayMs);
return;
}
startPlayback();
}
function onLoadedMetadata() {
if (video.readyState >= 2) {
schedulePlaybackStart();
}
}
// HAVE_CURRENT_DATA means a frame is available, not just the video's metadata.
if (video.readyState >= 2) {
schedulePlaybackStart();
return;
}
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
video.addEventListener('loadedmetadata', function () {
if (video.readyState >= 2) {
schedulePlaybackStart();
}
}, { once: true });
video.addEventListener('loadedmetadata', onLoadedMetadata, { once: true });
});
}
@@ -87,7 +125,28 @@ function initializeSlideMedia(root, delayMs) {
initializeRenderedVideoPlayback(root, delayMs);
}
function destroySlideMedia(root) {
function destroySlideMedia(root, options) {
if (!root) {
return;
}
var preserveVideoFrame = Boolean(options && options.preserveVideoFrame);
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
Array.prototype.forEach.call(videos, function (video) {
cancelRenderedVideoPlayback(video);
if (typeof video.pause === 'function') {
video.pause();
}
// Keep the paused frame through a crossfade; release decoder/network resources at removal.
if (!preserveVideoFrame) {
video.removeAttribute('src');
if (typeof video.load === 'function') {
video.load();
}
}
});
// RTMP players hold independent HLS resources beyond the lifetime of their DOM nodes.
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(root);
}
+33 -54
View File
@@ -1,5 +1,8 @@
var playlistRefreshInFlight = false;
// Render the slide at the requested index within the active set.
async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
var renderRequestId = ++slideRenderRequestId;
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (!availableSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
@@ -8,14 +11,14 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
}
let normalizedIndex = Number(targetIndex || 0);
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
if (!Number.isInteger(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
normalizedIndex = 0;
}
var slideCount = availableSlides.length;
var currentIndex = normalizedIndex;
var slide = null;
var attempts = 0;
var renderRequestId = ++slideRenderRequestId;
while (attempts < slideCount) {
slide = availableSlides[currentIndex];
@@ -35,7 +38,7 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
}
}
if (renderRequestId !== slideRenderRequestId) {
break;
return false;
}
break;
}
@@ -56,6 +59,13 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
var shouldFade = !(options && options.skipFade) && currentPlaylistFadeBetweenSlides;
var mediaDelayMs = slide && slide.use_video_duration ? 0 : undefined;
renderSlideMarkup(markup, shouldFade, mediaDelayMs);
if (typeof syncSlideMediaPreloads === 'function') {
syncSlideMediaPreloads(availableSlides, currentIndex);
}
if (typeof markPlayerPlaybackProgress === 'function') {
markPlayerPlaybackProgress();
}
savePlaybackState();
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(availableSlides, currentIndex);
}
@@ -67,20 +77,6 @@ async function renderSlideAtIndex(sourceSlides, targetIndex, options) {
return true;
}
// Calculate the configured time from slide entry to the next transition.
function getSlideAdvanceDelay(slide) {
var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000;
if (slide && slide.use_video_duration) {
return currentPlaylistFadeBetweenSlides
? getSlideHoldDelay(Math.max(1, durationMs - slideFadeLengthMs))
: getSlideHoldDelay(durationMs);
}
if (currentPlaylistFadeBetweenSlides) {
return getSlideHoldDelay(Math.max(1, durationMs - slideFadeOffsetMs));
}
return getSlideHoldDelay(durationMs);
}
// Promote a deferred playlist update at the next safe point.
function applyPendingPlaylistUpdate() {
if (!pendingPlaylistUpdate) {
@@ -117,15 +113,12 @@ function showCurrent(options) {
if (typeof scheduleSlideMarkupPreload === 'function') {
scheduleSlideMarkupPreload(activeSlides, index);
}
if (!activeSlides.length) {
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
return;
}
void renderSlideAtIndex(activeSlides, index, options);
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
}
var lastPlaylistRefreshAt = 0;
function isSlideInList(slide, slideList) {
if (!slide || !Array.isArray(slideList)) {
return false;
@@ -163,8 +156,16 @@ function handleRtmpPlaybackFailure(message, options) {
return true;
}
// Fetch the latest playlist and queue any updates.
function refresh(applyImmediately) {
// Fetch and queue data updates; rendering and pause/resume own the slide timer.
function refresh(applyImmediately, bypassDebounce) {
if (playlistRefreshInFlight) {
return;
}
if (!bypassDebounce && Date.now() - lastPlaylistRefreshAt < 1000) {
return;
}
playlistRefreshInFlight = true;
lastPlaylistRefreshAt = Date.now();
var request = new XMLHttpRequest();
var url = window.location.origin + '/api/screens/' + encodeURIComponent(slug) + '/playlist?ts=' + Date.now();
request.open('GET', url, true);
@@ -175,25 +176,26 @@ function refresh(applyImmediately) {
if (typeof getCommandClientId === 'function') {
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
}
if (currentPlaylistEtag) {
if (currentPlaylistEtag
&& initialData
&& Array.isArray(initialData.apiSources)
&& Array.isArray(initialData.weatherLocations)) {
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
}
request.onreadystatechange = function () {
if (request.readyState !== 4) {
return;
}
playlistRefreshInFlight = false;
if (request.status === 304) {
if (!initialData || !Array.isArray(initialData.apiSources) || !Array.isArray(initialData.weatherLocations)) {
currentPlaylistEtag = '';
refresh(applyImmediately);
refresh(applyImmediately, true);
return;
}
logDebug('Playlist refresh completed with no changes.');
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
return;
}
if (request.status < 200 || request.status >= 300) {
@@ -249,7 +251,6 @@ function refresh(applyImmediately) {
});
markRefreshHealthy();
setOfflineBannerVisible(false);
const currentActiveSlides = getCurrentActiveSlides();
if (responseEtag) {
currentPlaylistEtag = responseEtag;
}
@@ -268,9 +269,6 @@ function refresh(applyImmediately) {
showCurrent({ skipFade: true });
return;
}
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
return;
}
syncWebpagePreloads(nextActiveSlides, index);
@@ -287,16 +285,6 @@ function refresh(applyImmediately) {
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
if (applyImmediately) {
applyPendingPlaylistUpdate();
showCurrent();
return;
}
if (!isSlideInList(lastRenderedSlide, nextSlides)) {
applyPendingPlaylistUpdate();
showCurrent();
return;
}
logDebug('Playlist update detected; applying on next slide transition.');
return;
}
@@ -306,11 +294,6 @@ function refresh(applyImmediately) {
fadeBetweenSlides: nextFadeBetweenSlides,
skipUnavailableRtmp: nextSkipUnavailableRtmp
};
if (applyImmediately) {
applyPendingPlaylistUpdate();
showCurrent();
return;
}
logDebug('Playlist update detected; applying on next slide transition.');
} catch (_error) {
logDebug(
@@ -323,6 +306,7 @@ function refresh(applyImmediately) {
}
};
request.onerror = function () {
playlistRefreshInFlight = false;
logDebug(
'Unable to load screen playlist.',
['URL: ' + url, 'Network error during request.'].join(' | '),
@@ -330,11 +314,9 @@ function refresh(applyImmediately) {
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
};
request.ontimeout = function () {
playlistRefreshInFlight = false;
logDebug(
'Playlist refresh timed out.',
['URL: ' + url, 'Timeout after ' + request.timeout + 'ms'].join(' | '),
@@ -342,9 +324,6 @@ function refresh(applyImmediately) {
);
setOfflineBannerVisible(true);
scheduleRefreshRetry();
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideAdvanceDelay(lastRenderedSlide));
}
};
request.send();
}
@@ -158,6 +158,148 @@ function scheduleSlideMarkupPreload(sourceSlides, targetIndex) {
}, 0);
}
function deferVideoSourcesForPreload(markup) {
return String(markup || '').replace(/(<video\b[^>]*?)\s+src=("([^"]*)"|'([^']*)')/gi, function (_match, openingTag, quote, doubleQuoted, singleQuoted) {
const source = doubleQuoted !== undefined ? doubleQuoted : singleQuoted;
return openingTag + ' data-pulse-video-src=' + quote + source + quote;
});
}
function clearSlideMediaPreloads() {
mediaPreloadElements.forEach(function (element) {
if (!element || element.tagName !== 'VIDEO' || element.parentNode) {
return;
}
if (typeof element.pause === 'function') {
element.pause();
}
if (typeof element.removeAttribute === 'function') {
element.removeAttribute('src');
}
if (typeof element.load === 'function') {
element.load();
}
});
mediaPreloadElements = [];
mediaPreloadSignature = '';
}
function promotePreloadedVideoElements(root, slideId) {
if (!root || typeof root.querySelectorAll !== 'function') {
return 0;
}
var normalizedSlideId = String(slideId || '');
var promotedElements = [];
Array.prototype.forEach.call(root.querySelectorAll('.template-region.video video, .slide-media video'), function (activeVideo) {
var activeSource = String(activeVideo && (activeVideo.getAttribute('data-pulse-video-src') || activeVideo.getAttribute('src') || activeVideo.src) || '').trim();
if (!activeSource || !activeVideo.parentNode) {
return;
}
var absoluteSource = new URL(activeSource, window.location.origin).toString();
var preloadedVideo = mediaPreloadElements.find(function (element) {
return element && element.tagName === 'VIDEO'
&& String(element.__pulsePreloadSlideId || '') === normalizedSlideId
&& String(element.src || '').trim() === absoluteSource;
});
if (!preloadedVideo) {
if (activeVideo.getAttribute('data-pulse-video-src')) {
activeVideo.removeAttribute('data-pulse-video-src');
activeVideo.setAttribute('src', activeSource);
}
return;
}
Array.prototype.forEach.call(activeVideo.attributes || [], function (attribute) {
var attributeName = String(attribute.name || '').toLowerCase();
if (attributeName !== 'src' && attributeName !== 'data-pulse-video-src') {
preloadedVideo.setAttribute(attribute.name, attribute.value);
}
});
preloadedVideo.preload = activeVideo.preload || 'auto';
preloadedVideo.muted = Boolean(activeVideo.muted);
preloadedVideo.loop = Boolean(activeVideo.loop);
preloadedVideo.playsInline = Boolean(activeVideo.playsInline);
preloadedVideo.autoplay = Boolean(activeVideo.autoplay);
preloadedVideo.removeAttribute('data-pulse-video-src');
if (String(preloadedVideo.src || '').trim() !== absoluteSource) {
preloadedVideo.src = absoluteSource;
}
activeVideo.parentNode.replaceChild(preloadedVideo, activeVideo);
promotedElements.push(preloadedVideo);
});
if (promotedElements.length) {
mediaPreloadElements = mediaPreloadElements.filter(function (element) {
return promotedElements.indexOf(element) === -1;
});
}
return promotedElements.length;
}
function syncSlideMediaPreloads(sourceSlides, targetIndex) {
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
if (availableSlides.length < 2) {
clearSlideMediaPreloads();
return;
}
var currentIndex = Math.min(availableSlides.length - 1, Math.max(0, Number(targetIndex || 0)));
var nextSlide = availableSlides[(currentIndex + 1) % availableSlides.length];
if (!nextSlide || typeof primeSlideMarkup !== 'function' || typeof document === 'undefined') {
clearSlideMediaPreloads();
return;
}
var signature = [
currentPlaylistSignature || '',
nextSlide.id || '',
nextSlide.modified_at || '',
window.innerWidth + 'x' + window.innerHeight
].join('|');
if (signature === mediaPreloadSignature) {
return;
}
clearSlideMediaPreloads();
mediaPreloadSignature = signature;
var holder = document.createElement('div');
holder.innerHTML = deferVideoSourcesForPreload(primeSlideMarkup(nextSlide) || '');
var mediaUrls = [];
Array.prototype.forEach.call(holder.querySelectorAll('img[src], video[data-pulse-video-src]'), function (element) {
var isVideo = element.tagName === 'VIDEO';
var src = String(element.getAttribute(isVideo ? 'data-pulse-video-src' : 'src') || '').trim();
if (!src || mediaUrls.indexOf(src) !== -1) {
return;
}
mediaUrls.push(src);
var absoluteUrl = new URL(src, window.location.origin).toString();
if (element.tagName === 'VIDEO') {
var video = document.createElement('video');
Array.prototype.forEach.call(element.attributes || [], function (attribute) {
if (String(attribute.name || '').toLowerCase() !== 'src') {
video.setAttribute(attribute.name, attribute.value);
}
});
video.preload = 'auto';
video.muted = true;
video.playsInline = true;
video.__pulsePreloadSlideId = String(nextSlide.id || '');
video.src = absoluteUrl;
if (typeof video.load === 'function') {
video.load();
}
mediaPreloadElements.push(video);
return;
}
var image = new Image();
image.decoding = 'async';
image.src = absoluteUrl;
mediaPreloadElements.push(image);
});
}
// Mount hidden iframe preloads for the chosen webpage URLs.
function syncWebpagePreloads(sourceSlides, targetIndex) {
const urls = getWebpageUrls(getWebpagePreloadSlides(sourceSlides, targetIndex));
@@ -247,6 +389,7 @@ function savePlaylistSnapshot(data) {
slides: Array.isArray(data && data.slides) ? data.slides : [],
signature: String(data && data.signature || ''),
fadeBetweenSlides: Boolean(data && data.fadeBetweenSlides),
skipUnavailableRtmp: Boolean(data && data.skipUnavailableRtmp),
etag: String(data && data.etag || ''),
savedAt: new Date().toISOString()
}));
@@ -263,6 +406,7 @@ function applyPlaylistSnapshot(data) {
slides = data.slides.map(normalizeSlide);
currentPlaylistSignature = String(data.signature || '');
currentPlaylistFadeBetweenSlides = Boolean(data.fadeBetweenSlides);
currentPlaylistSkipUnavailableRtmp = Boolean(data.skipUnavailableRtmp);
currentPlaylistEtag = String(data.etag || '');
pendingPlaylistUpdate = null;
clearActiveSlidesCache();
@@ -273,3 +417,61 @@ function applyPlaylistSnapshot(data) {
index = 0;
return true;
}
function loadPlaybackState() {
try {
var raw = window.localStorage.getItem(playbackStateStorageKey);
if (!raw) {
return null;
}
var parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') {
return null;
}
return {
signature: String(parsed.signature || ''),
slideId: parsed.slideId === null || parsed.slideId === undefined ? null : String(parsed.slideId),
index: Number.isFinite(Number(parsed.index)) ? Number(parsed.index) : 0,
paused: Boolean(parsed.paused),
blackout: Boolean(parsed.blackout)
};
} catch (_error) {
return null;
}
}
function savePlaybackState() {
try {
window.localStorage.setItem(playbackStateStorageKey, JSON.stringify({
signature: String(currentPlaylistSignature || ''),
slideId: lastRenderedSlide && lastRenderedSlide.id !== undefined ? String(lastRenderedSlide.id) : null,
index: Number(index || 0),
paused: Boolean(isPaused),
blackout: Boolean(isBlackout),
savedAt: new Date().toISOString()
}));
} catch (_error) {
// Ignore storage errors so playback remains unaffected.
}
}
function restorePlaybackState() {
var state = loadPlaybackState();
if (!state || state.signature !== String(currentPlaylistSignature || '')) {
return false;
}
var restoredIndex = state.index;
var matchingSlideIndex = slides.findIndex(function (slide) {
return slide && state.slideId !== null && String(slide.id) === state.slideId;
});
if (matchingSlideIndex >= 0) {
restoredIndex = matchingSlideIndex;
}
if (slides.length) {
index = Math.min(Math.max(0, Number(restoredIndex) || 0), slides.length - 1);
}
isPaused = state.paused;
isBlackout = state.blackout;
return true;
}
+33 -311
View File
@@ -1,28 +1,21 @@
// General sanitization and sizing helpers.
// Strip unsupported characters from a font family string.
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,\"-]/g, '').trim();
}
function normalizeStyleAttributeValue(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;quot;/g, '"')
.replace(/&amp;#39;/g, "'");
}
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
function sanitizeTextColor(value, fallback) {
var raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
var playerRenderingShared = window.pulsePlayerRenderingShared;
var escapeHtml = playerRenderingShared.escapeHtml;
var normalizeStyleAttributeValue = playerRenderingShared.normalizeStyleAttributeValue;
var sanitizeFontFamily = playerRenderingShared.sanitizeFontFamily;
var sanitizeFontSize = playerRenderingShared.sanitizeFontSize;
var sanitizeTextColor = playerRenderingShared.sanitizeTextColor;
var sanitizeRichTextAttributes = playerRenderingShared.sanitizeRichTextAttributes;
var sanitizeRichText = playerRenderingShared.sanitizeRichText;
var renderEditorJsBlock = playerRenderingShared.renderEditorJsBlock;
var renderEditorJsListItem = playerRenderingShared.renderEditorJsListItem;
var renderEditorJsTable = playerRenderingShared.renderEditorJsTable;
var renderEditorJsContent = playerRenderingShared.renderEditorJsContent;
var parseMaybeJson = playerRenderingShared.parseMaybeJson;
var normalizeContentValue = playerRenderingShared.normalizeContentValue;
var fitCanvasSize = playerRenderingShared.fitCanvasSize;
var decorateRegionMarkup = playerRenderingShared.decorateRegionMarkup;
function getTemplateCanvasSize(template) {
return {
@@ -31,6 +24,17 @@ function getTemplateCanvasSize(template) {
};
}
function selectPlayerMediaSource(originalSource, variants, targetDimension) {
var original = String(originalSource || '').trim();
var target = Math.max(1, Number(targetDimension || 0) || 1);
var candidates = (Array.isArray(variants) ? variants : []).filter(function (variant) {
return variant && String(variant.url || '').trim() && Number(variant.width) >= target;
}).sort(function (left, right) {
return Number(left.width) - Number(right.width);
});
return candidates.length ? String(candidates[0].url) : original;
}
function getPlaylistRevision(data) {
if (data && data.revision) {
return String(data.revision);
@@ -44,36 +48,6 @@ function getPlaylistRevision(data) {
return String(Date.now());
}
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
var width = Math.max(1, Number(canvasWidth || 0) || 1920);
var height = Math.max(1, Number(canvasHeight || 0) || 1080);
var viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
var viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
var scale = Math.min(viewportWidth / width, viewportHeight / height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
function hasPlayerAnimation(config) {
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
var preset = String((config[stepName] && config[stepName].preset) || '').trim();
return preset && preset !== 'none';
}));
}
function decorateRegionMarkup(markup, region) {
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
return markup;
}
var animationJson = escapeHtml(JSON.stringify(region.animationConfig));
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (match, leadingWhitespace, tagName, attributes) {
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
});
}
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
if (!document || !document.documentElement) {
return;
@@ -85,264 +59,9 @@ function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
document.documentElement.style.setProperty('--player-canvas-height', height + 'px');
}
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
i: ['class', 'style', 'aria-hidden'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
tbody: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
thead: ['class', 'style'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
if (tagName === 'img') {
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
return '';
}
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
return '';
});
return attrs.join('');
}
// Remove unsafe markup while preserving richer rich-text formatting.
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var attrText = String(match[3] || '');
if (ALLOWED_RICH_TEXT_TAGS.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
});
}
// Render a single Editor.js block to HTML.
function renderEditorJsBlock(block) {
if (!block || !block.type || !block.data) {
return '';
}
if (block.type === 'header') {
var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
}
if (block.type === 'list') {
var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
var items = Array.isArray(block.data.items) ? block.data.items : [];
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
}
if (block.type === 'delimiter') {
return '<hr />';
}
if (block.type === 'code') {
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
}
if (block.type === 'table') {
return renderEditorJsTable(block.data);
}
if (block.type === 'paragraph') {
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
}
return '';
}
// Render a list item and any nested sub-items.
function renderEditorJsListItem(item, tag) {
if (item && typeof item === 'object') {
var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
}
return '<li>' + sanitizeRichText(item || '') + '</li>';
}
// Render an Editor.js table block.
function renderEditorJsTable(data) {
var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) {
return '';
}
var hasHeadings = Boolean(data.withHeadings);
var tableRows = rows.map(function (row, rowIndex) {
var cells = Array.isArray(row) ? row : [];
var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
return '<tr>' + cells.map(function (cell) {
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="ck-content-table">' + tableRows + '</table>';
}
function wrapRichTextParagraph(html) {
var raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
return raw;
}
return '<p>' + raw + '</p>';
}
// Render Editor.js JSON or plain content safely.
function renderEditorJsContent(value) {
if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) {
return value.blocks.map(renderEditorJsBlock).join('');
}
if (value.value !== undefined) {
return renderEditorJsContent(value.value);
}
}
var raw = String(value || '');
try {
var parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.blocks)) {
return parsed.blocks.map(renderEditorJsBlock).join('');
}
} catch (_error) {
// fall through to legacy HTML rendering
}
return wrapRichTextParagraph(sanitizeRichText(raw));
}
// Parse string values that look like JSON.
function parseMaybeJson(value) {
if (typeof value !== 'string') {
return value;
}
var raw = value.trim();
if (!raw) {
return value;
}
if (raw.charAt(0) !== '{' && raw.charAt(0) !== '[') {
return value;
}
try {
return JSON.parse(raw);
} catch (_error) {
return value;
}
}
// Normalize a slide region's stored content value.
function normalizeContentValue(value) {
var normalized;
if (!value || typeof value !== 'object') {
return {
type: 'text',
value: parseMaybeJson(value)
};
}
normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
if (normalized.value !== undefined) {
normalized.value = parseMaybeJson(normalized.value);
}
if (normalized.font_family !== undefined && normalized.font_family !== null) {
normalized.font_family = sanitizeFontFamily(normalized.font_family);
}
if (normalized.font_size !== undefined && normalized.font_size !== null) {
normalized.font_size = sanitizeFontSize(normalized.font_size);
}
if (normalized.font_color !== undefined && normalized.font_color !== null) {
normalized.font_color = sanitizeTextColor(normalized.font_color);
}
if (!normalized.type) {
normalized.type = 'text';
}
return normalized;
}
// Normalize a slide and its nested region content.
function normalizeSlide(slide) {
var normalized = {};
var content;
Object.keys(slide || {}).forEach(function (key) {
normalized[key] = slide[key];
});
normalized.content = {};
content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
});
var normalized = playerRenderingShared.normalizeSlide(slide);
if (normalized.use_video_duration && normalized.template && Array.isArray(normalized.template.regions)) {
normalized.template.regions.forEach(function (region) {
if (region && region.region_type === 'video' && normalized.content[region.region_key]) {
@@ -520,6 +239,8 @@ function getTemplateLayout(template) {
baseStyle: baseStyle,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
canvasWidth: templateCanvas.width,
canvasHeight: templateCanvas.height,
fontFamily: region.font_family || null,
fontSize: region.font_size || null,
fontColor: region.font_color || null,
@@ -530,7 +251,7 @@ function getTemplateLayout(template) {
var layout = {
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(selectPlayerMediaSource(template.background_image_path, template.background_image_variants, Math.max(templateCanvas.width, templateCanvas.height))) + '" alt="" />' : '',
backgroundColor: template.background_color || '#111111',
backgroundGradient: template.background_gradient || '',
regions: regions
@@ -641,7 +362,8 @@ function renderTemplateSlideMarkup(slide) {
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
});
}).join('') : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path, layout.backgroundGradient) : '';
const backgroundPath = layout ? selectPlayerMediaSource(template.background_image_path, template.background_image_variants, Math.max(layout.canvasWidth, layout.canvasHeight)) : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', backgroundPath, layout.backgroundGradient) : '';
if (layout) {
setPlayerCanvasDimensions(layout.canvasWidth, layout.canvasHeight);
}
+187 -2
View File
@@ -1,6 +1,56 @@
// Slide transition timing, cancellation, and outgoing animation coordination.
var slideOutroTimers = [];
var slideTransitionRequestId = 0;
var playerWatchdogTimer = null;
var playerLastProgressAt = Date.now();
var playerWatchdogRecoveryKey = 'pulse-signage-player-watchdog-recoveries';
function markPlayerPlaybackProgress() {
playerLastProgressAt = Date.now();
try {
window.sessionStorage.removeItem(playerWatchdogRecoveryKey);
} catch (_error) {
// Session storage may be unavailable in restricted kiosk profiles.
}
}
function startPlayerWatchdog() {
if (playerWatchdogTimer || typeof window.setInterval !== 'function') {
return;
}
playerWatchdogTimer = window.setInterval(function () {
if (document.visibilityState === 'hidden' || isPaused || !slides.length || !lastRenderedSlide) {
return;
}
var expectedDurationMs = Math.max(10000, Number(getSlideAdvanceDelay(lastRenderedSlide) || 10000));
if (Date.now() - playerLastProgressAt <= (expectedDurationMs * 2) + 15000) {
return;
}
var recoveryCount = 0;
try {
recoveryCount = Number(window.sessionStorage.getItem(playerWatchdogRecoveryKey) || 0);
} catch (_error) {
recoveryCount = 0;
}
if (recoveryCount >= 2) {
return;
}
try {
window.sessionStorage.setItem(playerWatchdogRecoveryKey, String(recoveryCount + 1));
} catch (_error) {
// Continue with the reload if session storage is unavailable.
}
if (typeof logDebug === 'function') {
logDebug('Player watchdog is reloading a stalled playback page.', 'No slide progress detected.', 'error');
}
window.location.reload();
}, 30000);
}
// Cancel the current slide-advance timer.
function clearSlideTimer() {
@@ -92,13 +142,148 @@ function getSlideHoldDelay(delayMs) {
// Calculate the configured time from slide entry to the next transition.
function getSlideAdvanceDelay(slide) {
return getSlideHoldDelay(Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000);
var durationMs = Math.max(1, Number(slide && slide.duration_seconds || 10)) * 1000;
// Video-duration slides start immediately; other slides start midway through the fade.
if (slide && slide.use_video_duration) {
return currentPlaylistFadeBetweenSlides
? getSlideHoldDelay(Math.max(1, durationMs - slideFadeLengthMs))
: getSlideHoldDelay(durationMs);
}
if (currentPlaylistFadeBetweenSlides) {
return getSlideHoldDelay(Math.max(1, durationMs - slideFadeOffsetMs));
}
return getSlideHoldDelay(durationMs);
}
// Cancel any pending fade-transition cleanup.
// Cancel fade cleanup and invalidate callbacks already queued for an older transition.
function clearSlideTransitionTimer() {
slideTransitionRequestId += 1;
if (slideTransitionTimer) {
window.clearTimeout(slideTransitionTimer);
slideTransitionTimer = null;
}
}
// Swap slide markup with optional fade animation.
function renderSlideMarkup(markup, shouldFade, mediaDelayMs) {
clearSlideTransitionTimer();
var transitionRequestId = slideTransitionRequestId;
if (typeof deferVideoSourcesForPreload === 'function') {
markup = deferVideoSourcesForPreload(markup);
}
// Stop outgoing region activity before overlapping its shell with the incoming slide.
destroyRegionInstances(app);
if (typeof destroySlideMedia === 'function') {
destroySlideMedia(app, { preserveVideoFrame: shouldFade });
}
function schedulePostRenderSetup(root, delayMs) {
if (!root) {
return;
}
if (root.isConnected === false) {
return;
}
initializeRegionInstances(root);
if (typeof initializeSlideMedia === 'function') {
initializeSlideMedia(root, delayMs);
}
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(root, 'intro');
}
}
if (!shouldFade) {
app.innerHTML = markup;
if (typeof promotePreloadedVideoElements === 'function') {
promotePreloadedVideoElements(app, lastRenderedSlide && lastRenderedSlide.id);
}
schedulePostRenderSetup(app, 0);
return app.firstElementChild;
}
var topLevelChildren = Array.prototype.slice.call(app.children || []);
var existingShells = topLevelChildren.filter(function (child) {
return child && child.classList && child.classList.contains('slide-shell');
});
var previousShell = existingShells.length ? existingShells[existingShells.length - 1] : app.firstElementChild;
if (existingShells.length > 1) {
// Rapid navigation can interrupt cleanup, leaving older crossfade shells behind.
existingShells.slice(0, -1).forEach(function (shell) {
if (shell && shell.parentNode) {
if (typeof destroySlideMedia === 'function') {
destroySlideMedia(shell);
}
shell.parentNode.removeChild(shell);
}
});
}
var nextShell = document.createElement('div');
nextShell.className = 'slide-shell slide-shell-entering';
nextShell.style.zIndex = '0';
nextShell.style.opacity = '0';
nextShell.innerHTML = markup;
if (typeof promotePreloadedVideoElements === 'function') {
promotePreloadedVideoElements(nextShell, lastRenderedSlide && lastRenderedSlide.id);
}
if (!previousShell || (previousShell.classList && previousShell.classList.contains('empty'))) {
if (previousShell && typeof destroySlideMedia === 'function') {
destroySlideMedia(app);
}
app.innerHTML = '';
nextShell.style.opacity = '1';
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
app.appendChild(nextShell);
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
return nextShell;
}
if (!previousShell.classList.contains('slide-shell')) {
previousShell.classList.add('slide-shell');
}
previousShell.classList.remove('is-visible');
previousShell.classList.add('is-exiting');
previousShell.style.zIndex = '1';
previousShell.style.opacity = '1';
app.appendChild(nextShell);
// Commit the initial opacity before starting the crossfade on the next paint frame.
window.requestAnimationFrame(function () {
if (transitionRequestId !== slideTransitionRequestId) {
return;
}
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
schedulePostRenderSetup(nextShell, mediaDelayMs === undefined ? slideFadeOffsetMs : mediaDelayMs);
});
slideTransitionTimer = window.setTimeout(function () {
if (transitionRequestId !== slideTransitionRequestId) {
return;
}
if (previousShell && previousShell.parentNode) {
if (typeof destroySlideMedia === 'function') {
destroySlideMedia(previousShell);
}
previousShell.parentNode.removeChild(previousShell);
}
if (nextShell) {
nextShell.style.zIndex = '1';
nextShell.style.opacity = '1';
nextShell.classList.remove('slide-shell-entering');
nextShell.classList.add('is-visible');
}
slideTransitionTimer = null;
}, slideFadeLengthMs);
return nextShell;
}
@@ -32,3 +32,38 @@
list: list
};
}());
function getPlayerRegionModules() {
if (!window.pulsePlayerRegionTypes || typeof window.pulsePlayerRegionTypes.list !== 'function') {
return [];
}
return window.pulsePlayerRegionTypes.list();
}
function runRegionLifecycle(root, lifecycleName) {
if (!root) {
return;
}
getPlayerRegionModules().forEach(function (entry) {
var module = entry && entry.definition ? entry.definition : null;
if (!module || typeof module[lifecycleName] !== 'function') {
return;
}
try {
module[lifecycleName](root);
} catch (_error) {
// Keep slide rendering resilient if a region lifecycle hook fails.
}
});
}
function destroyRegionInstances(root) {
runRegionLifecycle(root, 'destroyRegion');
}
function initializeRegionInstances(root) {
runRegionLifecycle(root, 'initRegion');
}
+27 -4
View File
@@ -5,6 +5,7 @@ const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
const PLAYLIST_CACHE = `pulse-signage-player-playlists-${CACHE_VERSION}`;
const PLAYER_CACHE_PREFIX = 'pulse-signage-player-';
function normalizeRequest(request) {
const url = new URL(request.url);
@@ -34,8 +35,13 @@ async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
if (!response || !response.ok) {
return;
}
const cache = await caches.open(cacheName);
await cache.put(cacheKeyRequest || request, response.clone());
// Cache storage is best-effort; quota or storage failures must not discard a live response.
try {
const cache = await caches.open(cacheName);
await cache.put(cacheKeyRequest || request, response.clone());
} catch (_error) {
return;
}
}
async function networkFirst(request, cacheName, cacheKeyRequest) {
@@ -114,10 +120,22 @@ self.addEventListener('install', function (event) {
self.addEventListener('activate', function (event) {
event.waitUntil((async function () {
const expected = [PAGE_CACHE, ASSET_CACHE, MEDIA_CACHE, PLAYLIST_CACHE];
const keys = await caches.keys();
const playerCacheVersions = keys.filter(function (key) {
return key.startsWith(PLAYER_CACHE_PREFIX);
}).map(function (key) {
return key.slice(PLAYER_CACHE_PREFIX.length).replace(/^(?:pages|assets|media|playlists)-/, '');
}).filter(Boolean);
const previousVersion = playerCacheVersions.reverse().find(function (version) {
return version !== CACHE_VERSION;
});
const retainedVersions = new Set([CACHE_VERSION, previousVersion].filter(Boolean));
await Promise.all(keys.filter(function (key) {
return expected.indexOf(key) === -1;
if (!key.startsWith(PLAYER_CACHE_PREFIX)) {
return false;
}
const version = key.slice(PLAYER_CACHE_PREFIX.length).replace(/^(?:pages|assets|media|playlists)-/, '');
return !retainedVersions.has(version);
}).map(function (key) {
return caches.delete(key);
}));
@@ -145,6 +163,11 @@ self.addEventListener('fetch', function (event) {
return;
}
if (url.pathname.startsWith('/assets/player-script/')) {
event.respondWith(networkFirst(request, ASSET_CACHE));
return;
}
if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
+1 -21
View File
@@ -112,28 +112,8 @@ function getApiPreviewFallback(item) {
return summary.join('');
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function renderApiRegion(region, regionContent) {
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var content = window.pulsePlayerRenderingShared.normalizeRegionValue(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'html']);
var sourceId = regionContent && regionContent.source_id !== undefined ? regionContent.source_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var itemsPath = regionContent && regionContent.items_path !== undefined ? regionContent.items_path : '';
+55
View File
@@ -0,0 +1,55 @@
// Chart region rendering and lifecycle management.
var registry = window.pulsePlayerRegionTypes;
var chartInstances = [];
function getChartData(regionContent) {
var sources = Array.isArray(window.initialData && window.initialData.apiSources) ? window.initialData.apiSources : [];
return window.pulseChartData.fromContent(regionContent || {}, sources);
}
function renderChartRegion(region, regionContent) {
var canvasId = 'player-chart-' + String(region && (region.regionKey || region.label) || '').replace(/[^a-zA-Z0-9_-]/g, '-');
var chartJson = escapeHtml(JSON.stringify(regionContent || {}));
return '<div class="template-region chart" style="' + region.baseStyle + 'position:relative;background:transparent;"><canvas id="' + canvasId + '" data-chart-json="' + chartJson + '" aria-label="Chart" style="width:100%;height:100%;display:block;background:transparent;"></canvas></div>';
}
function initChartRegions(root) {
if (typeof window.Chart !== 'function' || !window.pulseChartData || !root || !root.querySelectorAll) {
return;
}
root.querySelectorAll('.template-region.chart canvas').forEach(function (canvas) {
var region = canvas.parentElement;
var regionKey = region && region.getAttribute('data-region-key');
var content = {};
try {
content = JSON.parse(canvas.getAttribute('data-chart-json') || '{}');
} catch (_error) {
content = {};
}
var chartData = getChartData(content);
var chart = new window.Chart(canvas.getContext('2d'), {
type: chartData.type,
data: { labels: chartData.labels, datasets: chartData.datasets },
options: window.pulseChartData.getChartOptions(chartData)
});
chartInstances.push({ canvas: canvas, chart: chart, regionKey: regionKey });
});
}
function destroyChartRegions(root) {
chartInstances = chartInstances.filter(function (entry) {
if (!entry || !entry.canvas || !root || !root.contains(entry.canvas)) {
return true;
}
entry.chart.destroy();
return false;
});
}
registry.register('chart', {
renderRegion: renderChartRegion,
initRegion: initChartRegions,
destroyRegion: destroyChartRegions
});
+2 -35
View File
@@ -2,46 +2,13 @@
var registry = window.pulsePlayerRegionTypes;
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.html !== undefined) {
return normalizeRenderableValue(value.html);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function buildHtmlDocument(html) {
var raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
return raw;
}
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}html,body{background:transparent !important;}</style></head><body>' + raw + '</body></html>';
}
function renderHtmlRegionContent(value) {
var html = normalizeRenderableValue(value).trim();
var html = window.pulsePlayerRenderingShared.normalizeRenderableString(value, ['value', 'text', 'html', 'content']).trim();
if (!html) {
return '';
}
if (/^<!doctype\b/i.test(html) || /^<html\b/i.test(html)) {
return '<iframe class="template-region-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(html) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
return '<iframe class="template-region-html-frame" sandbox="" allowtransparency="true" scrolling="no" srcdoc="' + escapeHtml(window.pulsePlayerRenderingShared.buildHtmlDocument(html, true)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
return '<div class="template-region-html-content" style="width:100%;height:100%;overflow:hidden;background:transparent;">' + html + '</div>';
}
+1 -1
View File
@@ -3,7 +3,7 @@
var registry = window.pulsePlayerRegionTypes;
function renderImageRegion(region, regionContent) {
var src = regionContent.value || '';
var src = selectPlayerMediaSource(regionContent.value || '', regionContent.media_variants, Math.max(region.canvasWidth || 0, region.canvasHeight || 0));
if (!String(src || '').trim()) {
return '';
}
+1 -21
View File
@@ -11,26 +11,6 @@ function getDefaultStyle() {
};
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function getRssFeedById(feedId) {
var feeds = Array.isArray(initialData && initialData.rssFeeds) ? initialData.rssFeeds : [];
var normalizedId = Number(feedId || 0);
@@ -99,7 +79,7 @@ function substituteRssVariables(html, item, feedId) {
}
function renderRssRegion(region, regionContent) {
var content = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var content = window.pulsePlayerRenderingShared.normalizeRegionValue(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'html']);
var feedId = regionContent && regionContent.feed_id !== undefined ? regionContent.feed_id : null;
var itemNumber = regionContent && regionContent.item_number !== undefined ? regionContent.item_number : 1;
var defaultStyle = getDefaultStyle();
+1 -21
View File
@@ -10,28 +10,8 @@ function getDefaultStyle() {
};
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return value.value;
}
if (value.text !== undefined) {
return value.text;
}
if (value.html !== undefined) {
return value.html;
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function renderTextRegion(region, regionContent) {
var rawValue = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '');
var rawValue = window.pulsePlayerRenderingShared.normalizeRegionValue(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'html']);
if (!String(rawValue || '').trim()) {
return '';
}
+5 -1
View File
@@ -1,5 +1,6 @@
// Video region playback, retry, and source probe helpers.
// These page-lifetime caches retain fallback sources while mirrored replacements are probed.
var videoRegionLastGoodSrcCache = Object.create(null);
var videoRegionProbeStateCache = Object.create(null);
var videoRegionProbeTimerCache = Object.create(null);
@@ -10,6 +11,7 @@ function getVideoRegionCacheKey(region) {
}
function isDirectlyRenderableSource(src) {
// URL-shaped sources go directly to the browser; this bypass is not a readiness check.
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
}
@@ -67,6 +69,7 @@ function scheduleVideoSourceProbe(regionKey, src, isRetry) {
return;
}
// Regions sharing a source also share its pending probe, avoiding duplicate HEAD requests.
if (videoRegionProbeTimerCache[key]) {
return;
}
@@ -106,7 +109,7 @@ function scheduleVideoSourceProbe(regionKey, src, isRetry) {
}
function renderVideoRegion(region, regionContent) {
var requestedSrc = String(regionContent && regionContent.value || '').trim();
var requestedSrc = selectPlayerMediaSource(regionContent && regionContent.value || '', regionContent && regionContent.media_variants, Math.max(region.canvasWidth || 0, region.canvasHeight || 0));
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
@@ -139,6 +142,7 @@ function renderVideoRegion(region, regionContent) {
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
// Keep the previous source visible while the requested mirrored file is unavailable.
if (cachedSrc) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
+1 -27
View File
@@ -2,34 +2,8 @@
var registry = window.pulsePlayerRegionTypes;
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.url !== undefined) {
return normalizeRenderableValue(value.url);
}
if (value.href !== undefined) {
return normalizeRenderableValue(value.href);
}
if (value.src !== undefined) {
return normalizeRenderableValue(value.src);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function renderWebpageRegion(region, regionContent) {
var url = normalizeRenderableValue(regionContent && regionContent.value !== undefined ? regionContent.value : '').trim();
var url = window.pulsePlayerRenderingShared.normalizeRenderableString(regionContent && regionContent.value !== undefined ? regionContent.value : '', ['value', 'text', 'url', 'href', 'src', 'content']).trim();
if (!url) {
return '';
}
+23 -338
View File
@@ -3,6 +3,25 @@
const fs = require('fs');
const path = require('path');
const announcementIcons = require('#src/data/announcement-icons');
const renderingShared = require('#src/player/rendering-shared');
const {
escapeHtml,
sanitizeRichTextAttributes,
sanitizeRichText,
renderEditorJsBlock,
renderEditorJsListItem,
renderEditorJsTable,
renderEditorJsContent,
normalizeRenderableString,
buildHtmlDocument,
sanitizeFontFamily,
sanitizeFontSize,
sanitizeTextColor,
parseMaybeJson,
normalizeContentValue,
normalizeSlide,
fitCanvasSize
} = renderingShared;
function mediaKind(mediaPath) {
const ext = path.extname(mediaPath || '').toLowerCase();
@@ -18,331 +37,18 @@ function mediaKind(mediaPath) {
return 'file';
}
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeStyleAttributeValue(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;quot;/g, '"')
.replace(/&amp;#39;/g, "'");
}
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
}
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
const ALLOWED_RICH_TEXT_TAGS = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
i: ['class', 'style', 'aria-hidden'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
if (tagName === 'img') {
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
return '';
}
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) => {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
const targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
return '';
});
return attrs.join('');
}
function safeJsonForScript(value) {
return JSON.stringify(value === undefined ? null : value).replace(/</g, '\\u003c');
}
function parseMaybeJson(value) {
if (typeof value !== 'string') {
return value;
}
const raw = value.trim();
if (!raw) {
return value;
}
if (raw[0] !== '{' && raw[0] !== '[') {
return value;
}
try {
return JSON.parse(raw);
} catch (_error) {
return value;
}
}
function normalizeContentValue(value) {
if (!value || typeof value !== 'object') {
return {
type: 'text',
value: parseMaybeJson(value)
};
}
const normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
if (normalized.value !== undefined) {
normalized.value = parseMaybeJson(normalized.value);
} else if (normalized.font_family !== undefined && normalized.font_family !== null) {
normalized.font_family = sanitizeFontFamily(normalized.font_family);
} else if (normalized.font_size !== undefined && normalized.font_size !== null) {
normalized.font_size = sanitizeFontSize(normalized.font_size);
} else if (normalized.font_color !== undefined && normalized.font_color !== null) {
normalized.font_color = sanitizeTextColor(normalized.font_color);
} else if (!normalized.type) {
normalized.type = 'text';
}
return normalized;
}
function normalizeSlide(slide) {
const normalized = {};
Object.keys(slide || {}).forEach(function (key) {
normalized[key] = slide[key];
});
normalized.content = {};
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
});
return normalized;
}
function sanitizeRichText(html) {
let output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, (tag) => {
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
const attrText = String(match[3] || '');
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return '';
}
if (closing) {
return `</${name}>`;
}
return `<${name}${sanitizeRichTextAttributes(name, attrText)}>`;
});
}
function renderEditorJsBlock(block) {
if (!block || !block.type || !block.data) {
return '';
}
if (block.type === 'header') {
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
} else if (block.type === 'list') {
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
const items = Array.isArray(block.data.items) ? block.data.items : [];
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map((item) => renderEditorJsListItem(item, tag)).join('') + '</' + tag + '>';
} else if (block.type === 'delimiter') {
return '<hr />';
} else if (block.type === 'code') {
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
} else if (block.type === 'table') {
return renderEditorJsTable(block.data);
} else if (block.type === 'paragraph') {
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
}
return '';
}
function renderEditorJsListItem(item, tag) {
if (item && typeof item === 'object') {
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map((child) => renderEditorJsListItem(child, tag)).join('') + '</' + tag + '>' : '';
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
}
return '<li>' + sanitizeRichText(item || '') + '</li>';
}
function renderEditorJsTable(data) {
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) {
return '';
}
const hasHeadings = Boolean(data.withHeadings);
const tableRows = rows.map(function (row, rowIndex) {
const cells = Array.isArray(row) ? row : [];
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
return '<tr>' + cells.map(function (cell) {
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="ck-content-table">' + tableRows + '</table>';
}
function wrapRichTextParagraph(html) {
const raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
return raw;
}
return '<p>' + raw + '</p>';
}
function renderEditorJsContent(value) {
if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) {
return value.blocks.map(renderEditorJsBlock).join('');
}
if (value.value !== undefined) {
return renderEditorJsContent(value.value);
}
}
const raw = String(value || '');
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.blocks)) {
return parsed.blocks.map(renderEditorJsBlock).join('');
}
} catch (_error) {
// fall through to legacy HTML rendering
}
return wrapRichTextParagraph(sanitizeRichText(raw));
}
function buildHtmlDocument(html) {
const raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
return raw;
}
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + raw + '</body></html>';
}
function renderHtmlRegionContent(value) {
const html = normalizeRenderableValue(value).trim();
const html = normalizeRenderableString(value, ['value', 'text', 'html', 'content']).trim();
if (!html) {
return '<div class="template-region-placeholder">HTML</div>';
}
return '<iframe class="template-region-html-frame" sandbox="" srcdoc="' + escapeHtml(buildHtmlDocument(html)) + '" title="HTML region" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
function normalizeRenderableValue(value) {
if (value && typeof value === 'object') {
if (value.value !== undefined) {
return normalizeRenderableValue(value.value);
}
if (value.text !== undefined) {
return normalizeRenderableValue(value.text);
}
if (value.html !== undefined) {
return normalizeRenderableValue(value.html);
}
if (value.content !== undefined) {
return normalizeRenderableValue(value.content);
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
const scale = Math.min(viewportWidth / width, viewportHeight / height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
const playerPageTemplatePath = path.join(__dirname, 'player-page.template.html');
const playerClientNameScriptPath = path.join(__dirname, 'player-client-name.script.html');
const playerPageOfflineScriptPath = path.join(__dirname, 'public', 'js', 'player-page-offline.js');
@@ -359,6 +65,7 @@ const playerAnnouncementTopBannerTemplatePath = path.join(__dirname, 'announceme
const playerAnnouncementFullscreenTemplatePath = path.join(__dirname, 'announcement-templates', 'fullscreen.template.html');
const playerPageAnnouncementsScriptPath = path.join(__dirname, 'player-page-announcements.js');
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerRenderingSharedScriptPath = path.join(__dirname, 'rendering-shared.js');
const playerRegionScriptDir = path.join(__dirname, 'regions');
const playerOnboardingLandingScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-landing.script.html');
const playerOnboardingFormScriptPath = path.join(__dirname, 'onboarding', 'player-onboarding-form.script.html');
@@ -373,7 +80,6 @@ let playerAnnouncementTemplatesScriptCache = null;
let playerAnnouncementTemplatesDataScriptCache = null;
let playerPageAnnouncementsScriptCache = null;
let playerPageScriptCache = null;
let playerRegionScriptsCache = null;
let playerOnboardingLandingScriptCache = null;
let playerOnboardingFormScriptCache = null;
@@ -498,28 +204,6 @@ function getPlayerRegionScriptPaths() {
});
}
function getPlayerRegionScripts() {
const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js');
const sharedQrSvgUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js');
const playerRegionScriptPaths = getPlayerRegionScriptPaths();
const scriptPaths = [sharedPlaceholderUtilsPath, sharedQrSvgUtilsPath].concat(playerRegionScriptPaths);
const statSignature = scriptPaths.map(function (filePath) {
return fs.statSync(filePath).mtimeMs;
}).join('|');
if (playerRegionScriptsCache && playerRegionScriptsCache.signature === statSignature) {
return playerRegionScriptsCache.value;
}
const value = scriptPaths.map(function (filePath) {
return fs.readFileSync(filePath, 'utf8').trim();
}).join('\n\n');
playerRegionScriptsCache = {
signature: statSignature,
value: value
};
return value;
}
function getPlayerOnboardingLandingScript() {
return loadTemplate(playerOnboardingLandingScriptPath, playerOnboardingLandingScriptCache || (playerOnboardingLandingScriptCache = {}));
}
@@ -539,7 +223,9 @@ function getPlayerRuntimeScripts() {
return [
['region-registry', fs.readFileSync(path.join(__dirname, 'public', 'js', 'player-region-registry.js'), 'utf8').trim()],
['placeholder-utils', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js'), 'utf8').trim()],
['chart-data', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'regions', 'chart-data.js'), 'utf8').trim()],
['qr-code-svg', fs.readFileSync(path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js'), 'utf8').trim()],
['rendering-shared', fs.readFileSync(playerRenderingSharedScriptPath, 'utf8').trim()],
['client-name', getScriptBody(getPlayerClientNameScript()())],
['offline', getScriptBody(getPlayerPageOfflineScript()())],
['playlist', getScriptBody(getPlayerPagePlaylistScript()())],
@@ -588,7 +274,6 @@ module.exports = {
getPlayerAnnouncementTemplatesScript: getPlayerAnnouncementTemplatesScript,
getPlayerPageAnnouncementsScript: getPlayerPageAnnouncementsScript,
getPlayerPageScript: getPlayerPageScript,
getPlayerRegionScripts: getPlayerRegionScripts,
getPlayerOnboardingLandingScript: getPlayerOnboardingLandingScript,
getPlayerOnboardingFormScript: getPlayerOnboardingFormScript,
getPlayerRuntimeScripts: getPlayerRuntimeScripts
+12 -16
View File
@@ -1,9 +1,10 @@
// Player page rendering and bootstrap script assembly.
const Handlebars = require('handlebars');
const crypto = require('crypto');
const path = require('path');
const packageMetadata = require('../../package.json');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('./render-helpers');
const packageMetadata = require('#root/package.json');
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript, getPlayerRuntimeScripts } = require('#src/player/render-helpers');
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
@@ -100,20 +101,11 @@ function renderOnboardingFormBody(deviceId) {
}
function renderOnboardingLandingScript() {
return getPlayerOnboardingLandingScript()();
}
function renderOnboardingFormScript(deviceId) {
return getPlayerOnboardingFormScript()({
DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || ''))
});
}
function renderPlayerPage(slug, initialData) {
const serviceWorkerScript = getPlayerServiceWorkerRegistrationScript();
const template = getPlayerPageTemplate();
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const chartScriptTag = '<script src="/assets/vendor/chart.js/chart.umd.js"></script>';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
const bootstrapScript = getPlayerPageScript()({
@@ -122,7 +114,9 @@ function renderPlayerPage(slug, initialData) {
REGION_SCRIPTS: ''
});
const runtimeScriptTags = getPlayerRuntimeScripts().map(function (entry) {
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js?v=' + encodeURIComponent(String(packageMetadata.version || 'development')) + '"></script>';
const scriptRevision = crypto.createHash('sha256').update(entry[1]).digest('hex').slice(0, 12);
const assetVersion = String(packageMetadata.version || 'development') + '-' + scriptRevision;
return '<script src="/assets/player-script/' + encodeURIComponent(entry[0]) + '.js?v=' + encodeURIComponent(assetVersion) + '"></script>';
}).join('');
return renderPage(template, {
@@ -130,7 +124,7 @@ function renderPlayerPage(slug, initialData) {
bodyClass: '',
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + chartScriptTag + serviceWorkerScript + runtimeScriptTags + bootstrapScript + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
});
}
@@ -142,7 +136,7 @@ function renderPlayerOnboardingLandingPage(options) {
bodyClass: 'onboarding-page',
body: renderOnboardingLandingBody(options),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + getPlayerOnboardingLandingScript()()
});
}
@@ -154,7 +148,9 @@ function renderPlayerOnboardingFormPage(deviceId) {
bodyClass: 'onboarding-page',
body: renderOnboardingFormBody(deviceId),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingFormScript(deviceId)
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + getPlayerOnboardingFormScript()({
DEVICE_ID_JSON: new Handlebars.SafeString(JSON.stringify(deviceId || ''))
})
});
}
+376
View File
@@ -0,0 +1,376 @@
(function (root, factory) {
const shared = factory();
if (typeof module === 'object' && module.exports) {
module.exports = shared;
} else if (root) {
root.pulsePlayerRenderingShared = shared;
}
}(typeof window !== 'undefined' ? window : globalThis, function () {
const allowedTags = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function normalizeStyleAttributeValue(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/&amp;quot;/g, '"')
.replace(/&amp;#39;/g, "'");
}
function sanitizeRichTextAttributes(tagName, attrText) {
const allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
img: ['src', 'alt', 'title', 'width', 'height', 'class', 'style', 'loading', 'decoding'],
i: ['class', 'style', 'aria-hidden'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
tbody: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
thead: ['class', 'style'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
const allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
if (tagName === 'img') {
const srcMatch = String(attrText || '').match(/\bsrc\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+))/i);
const srcValue = srcMatch ? String(srcMatch[2] !== undefined ? srcMatch[2] : srcMatch[3] !== undefined ? srcMatch[3] : srcMatch[4] !== undefined ? srcMatch[4] : '').trim() : '';
if (!srcValue || !/^(?:https?:\/\/|\/media\/|\/assets\/|\/[^/]|data:image\/)/i.test(srcValue)) {
return '';
}
}
const attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
const lowerKey = String(key || '').toLowerCase();
if (!allowed.includes(lowerKey)) {
return '';
}
const value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target' && String(value || '').trim() === '_blank') {
attrs.push(' target="_blank"');
if (!attrs.includes(' rel="noreferrer noopener"')) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(lowerKey === 'style' ? normalizeStyleAttributeValue(value) : value) + '"');
return '';
});
return attrs.join('');
}
function sanitizeRichText(html) {
let output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
const attrText = String(match[3] || '');
if (!allowedTags.includes(name)) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeRichTextAttributes(name, attrText) + '>';
});
}
function renderEditorJsListItem(item, tag) {
if (item && typeof item === 'object') {
const content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
const children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
const nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderEditorJsListItem(child, tag); }).join('') + '</' + tag + '>' : '';
return '<li>' + sanitizeRichText(content || '') + nested + '</li>';
}
return '<li>' + sanitizeRichText(item || '') + '</li>';
}
function renderEditorJsTable(data) {
const rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
if (!rows.length) {
return '';
}
const hasHeadings = Boolean(data.withHeadings);
const tableRows = rows.map(function (row, rowIndex) {
const cells = Array.isArray(row) ? row : [];
const cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
const cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
return '<tr>' + cells.map(function (cell) {
return '<' + cellTag + cellAttrs + '>' + sanitizeRichText(cell || '') + '</' + cellTag + '>';
}).join('') + '</tr>';
}).join('');
return '<table class="ck-content-table">' + tableRows + '</table>';
}
function renderEditorJsBlock(block) {
if (!block || !block.type || !block.data) {
return '';
}
if (block.type === 'header') {
const level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
return '<h' + level + '>' + sanitizeRichText(block.data.text || '') + '</h' + level + '>';
}
if (block.type === 'list') {
const tag = block.data.style === 'ordered' ? 'ol' : 'ul';
const items = Array.isArray(block.data.items) ? block.data.items : [];
return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderEditorJsListItem(item, tag); }).join('') + '</' + tag + '>';
}
if (block.type === 'delimiter') {
return '<hr />';
}
if (block.type === 'code') {
return '<pre><code>' + escapeHtml(block.data.code || '') + '</code></pre>';
}
if (block.type === 'table') {
return renderEditorJsTable(block.data);
}
if (block.type === 'paragraph') {
return '<p>' + sanitizeRichText(block.data.text || '') + '</p>';
}
return '';
}
function wrapRichTextParagraph(html) {
const raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<\s*(?:p|div|h[1-6]|ul|ol|pre|table|blockquote|figure|hr|li)\b/i.test(raw)) {
return raw;
}
return '<p>' + raw + '</p>';
}
function renderEditorJsContent(value) {
if (value && typeof value === 'object') {
if (Array.isArray(value.blocks)) {
return value.blocks.map(renderEditorJsBlock).join('');
}
if (value.value !== undefined) {
return renderEditorJsContent(value.value);
}
}
const raw = String(value || '');
try {
const parsed = JSON.parse(raw);
if (parsed && Array.isArray(parsed.blocks)) {
return parsed.blocks.map(renderEditorJsBlock).join('');
}
} catch (_error) {
// Continue with legacy HTML content when the value is not Editor.js JSON.
}
return wrapRichTextParagraph(sanitizeRichText(raw));
}
function normalizeRenderableString(value, propertyKeys) {
if (value && typeof value === 'object') {
const keys = Array.isArray(propertyKeys) ? propertyKeys : ['value', 'text', 'html', 'content'];
for (const key of keys) {
if (value[key] !== undefined) {
return normalizeRenderableString(value[key], keys);
}
}
return '';
}
return String(value === undefined || value === null ? '' : value);
}
function normalizeRegionValue(value, propertyKeys) {
if (value && typeof value === 'object') {
const keys = Array.isArray(propertyKeys) ? propertyKeys : ['value', 'text', 'html'];
for (const key of keys) {
if (value[key] !== undefined) {
return value[key];
}
}
try {
return JSON.stringify(value);
} catch (_error) {
return '';
}
}
return value;
}
function buildHtmlDocument(html, forceTransparentBackground) {
const raw = String(html || '').trim();
if (!raw) {
return '';
}
if (/^<!doctype\b/i.test(raw) || /^<html\b/i.test(raw)) {
return raw;
}
const transparencyRule = forceTransparentBackground ? 'html,body{background:transparent !important;}' : '';
return '<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}' + transparencyRule + '</style></head><body>' + raw + '</body></html>';
}
function sanitizeFontFamily(value) {
return String(value || '').replace(/[^a-zA-Z0-9 ,"-]/g, '').trim();
}
function sanitizeFontSize(value) {
return Math.max(8, Number(value || 0) || 24);
}
function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
function parseMaybeJson(value) {
if (typeof value !== 'string') {
return value;
}
const raw = value.trim();
if (!raw || (raw[0] !== '{' && raw[0] !== '[')) {
return value;
}
try {
return JSON.parse(raw);
} catch (_error) {
return value;
}
}
function normalizeContentValue(value) {
if (!value || typeof value !== 'object') {
return {
type: 'text',
value: parseMaybeJson(value)
};
}
const normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
if (normalized.value !== undefined) {
normalized.value = parseMaybeJson(normalized.value);
}
if (normalized.font_family !== undefined && normalized.font_family !== null) {
normalized.font_family = sanitizeFontFamily(normalized.font_family);
}
if (normalized.font_size !== undefined && normalized.font_size !== null) {
normalized.font_size = sanitizeFontSize(normalized.font_size);
}
if (normalized.font_color !== undefined && normalized.font_color !== null) {
normalized.font_color = sanitizeTextColor(normalized.font_color);
}
if (!normalized.type) {
normalized.type = 'text';
}
return normalized;
}
function normalizeSlide(slide) {
const normalized = {};
Object.keys(slide || {}).forEach(function (key) {
normalized[key] = slide[key];
});
normalized.content = {};
const content = slide && slide.content && typeof slide.content === 'object' ? slide.content : {};
Object.keys(content).forEach(function (regionKey) {
normalized.content[regionKey] = normalizeContentValue(content[regionKey]);
});
return normalized;
}
function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
const width = Math.max(1, Number(canvasWidth || 0) || 1920);
const height = Math.max(1, Number(canvasHeight || 0) || 1080);
const viewportWidth = Math.max(1, Number(maxWidth || 0) || width);
const viewportHeight = Math.max(1, Number(maxHeight || 0) || height);
const scale = Math.min(viewportWidth / width, viewportHeight / height);
return {
width: Math.round(width * scale),
height: Math.round(height * scale)
};
}
function hasPlayerAnimation(config) {
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
const preset = String((config[stepName] && config[stepName].preset) || '').trim();
return preset && preset !== 'none';
}));
}
function decorateRegionMarkup(markup, region) {
if (!markup || !region || !hasPlayerAnimation(region.animationConfig)) {
return markup;
}
const animationJson = escapeHtml(JSON.stringify(region.animationConfig));
return markup.replace(/^(\s*)<([a-z0-9-]+)(\s[^>]*)?>/i, function (_match, leadingWhitespace, tagName, attributes) {
return leadingWhitespace + '<' + tagName + (attributes || '') + ' data-animation-json="' + animationJson + '">';
});
}
return {
escapeHtml: escapeHtml,
normalizeStyleAttributeValue: normalizeStyleAttributeValue,
sanitizeRichTextAttributes: sanitizeRichTextAttributes,
sanitizeRichText: sanitizeRichText,
renderEditorJsBlock: renderEditorJsBlock,
renderEditorJsListItem: renderEditorJsListItem,
renderEditorJsTable: renderEditorJsTable,
renderEditorJsContent: renderEditorJsContent,
normalizeRenderableString: normalizeRenderableString,
normalizeRegionValue: normalizeRegionValue,
buildHtmlDocument: buildHtmlDocument,
sanitizeFontFamily: sanitizeFontFamily,
sanitizeFontSize: sanitizeFontSize,
sanitizeTextColor: sanitizeTextColor,
parseMaybeJson: parseMaybeJson,
normalizeContentValue: normalizeContentValue,
normalizeSlide: normalizeSlide,
fitCanvasSize: fitCanvasSize,
hasPlayerAnimation: hasPlayerAnimation,
decorateRegionMarkup: decorateRegionMarkup
};
}));
+10 -2
View File
@@ -3,8 +3,9 @@
const fs = require('fs');
const express = require('express');
const path = require('path');
const crypto = require('crypto');
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { getPlayerPublicBaseUrl } = require('./onboarding');
const { getPlayerPublicBaseUrl } = require('#src/player/onboarding/index');
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
@@ -269,6 +270,7 @@ function registerPlayerRoutes(app, options) {
app.use('/assets', express.static(assetDir));
app.use('/assets/adminlte/bootstrap-icons', express.static(path.join(__dirname, '..', 'web', 'public', 'adminlte', 'bootstrap-icons')));
app.use('/assets/vendor/chart.js', express.static(path.join(__dirname, '..', 'web', 'public', 'vendor', 'chart.js')));
app.use('/media', express.static(mediaDir));
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
@@ -336,7 +338,13 @@ function registerPlayerRoutes(app, options) {
}
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
await fs.promises.writeFile(filePath, body);
const temporaryFilePath = filePath + '.partial-' + crypto.randomUUID();
try {
await fs.promises.writeFile(temporaryFilePath, body);
await fs.promises.rename(temporaryFilePath, filePath);
} finally {
await fs.promises.unlink(temporaryFilePath).catch(function () {});
}
res.json({ ok: true, filename: req.params.filename });
} catch (error) {
next(error);
+6 -6
View File
@@ -5,25 +5,25 @@ const http = require('http');
const multer = require('multer');
const fs = require('fs');
const crypto = require('crypto');
const common = require('./common');
const common = require('#src/common');
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength, createOneTimeToken } = require('#src/auth');
const pages = require('#src/web/pages');
const pages = require('#src/web/pages/index');
const registerMiddleware = require('#src/web/middleware');
const registerNotFoundHandler = require('#src/web/middleware/not-found');
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks/index');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
const { captureSlideThumbnail } = require('#src/web/lib/media');
const { captureSlideThumbnail } = require('#src/web/lib/media/index');
const { createWebBootstrap } = require('#src/web/bootstrap');
const { registerRoutes } = require('#src/web/routes/register');
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
const { initializeWebServer } = require('#src/web/lib/web-startup');
const { createWebConfig } = require('#src/web/lib/config');
const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
const { rbacData } = require('#src/web/lib/auth');
const { rbacData } = require('#src/web/lib/auth/index');
const { createPlayerActionService } = require('#src/web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth');
const { createSessionService, getRequestOrigin } = require('#src/web/lib/auth/index');
const { fetchAppSettings } = require('#src/data/app-settings');
const { sendAccountEmail } = require('#src/data/mailer');
const { recordRequestAuditEvent } = require('#src/data/audit-log');
+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));
Loaded 100 of 174 files, more files were not shown because too many files have changed in this diff. Show more