Compare commits

...
3 Commits
Author SHA1 Message Date
lzstealth 9d5029eff6 Release 2.5.2 2026-08-03 19:18:30 +01:00
lzstealth 37345f3949 Document template region schema 2026-08-03 17:30:19 +01:00
lzstealth ed8d51580e Add template animation editor 2026-08-03 17:27:42 +01:00
47 changed files with 3368 additions and 93 deletions
+1
View File
@@ -3,6 +3,7 @@ media/
!src/web/lib/media/
!src/web/lib/media/**
docker-compose.dev.yml
/dev-demo-seed.js
.vscode/
.env
npm-debug.log*
+42
View File
@@ -2,6 +2,48 @@
All notable changes to this project will be documented in this file.
## 2.5.2 - 2026-08-03
### Changed
- Player page auth now writes a cookie for websocket reuse, and player and announcement sockets accept that cookie so they no longer depend on query-string tokens.
- RSS and API region placeholder panels now use shared field-aware chip rendering, with updated spacing and defaults across the slide editor.
- RTMP regions now default audio to enabled in the editor, and the slide rich-text editor no longer allows anchor tags.
### Fixed
- Player websocket auth now falls back to the `pulse_page_auth` cookie when the auth query parameter is unavailable.
## 2.5.1 - 2026-08-03
### Added
- Template regions now include a dedicated advanced animation modal for editing intro, outro, and Attention Seekers settings directly.
### Changed
- Template region animation settings now store as a single JSON object with `intro`, `outro`, and `loop` keys.
- The template editor advanced animation field now edits the JSON object directly instead of separate preset selects.
- Animate.css is now loaded from vendored assets so template previews and player rendering use the local copy.
### Fixed
- Existing databases now migrate legacy animation columns into `animation_json` and then drop the old columns.
## 2.5.0 - 2026-08-03
### Added
- Template regions now support intro, exit, and Attention Seekers animation presets in the editor, with in-canvas preview controls.
### Changed
- The template editor now locks editing while preview playback is running so animation changes are easier to inspect.
### Fixed
- Existing databases now receive the template region animation columns through the versioned migration path.
## 2.4.2 - 2026-08-02
### Added
+3
View File
@@ -9,6 +9,9 @@ RUN npm install --omit=dev --no-audit --no-fund
COPY src ./src
RUN mkdir -p /app/src/web/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/web/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/src/player/public/vendor/animate.css && cp /app/node_modules/animate.css/animate.min.css /app/src/player/public/vendor/animate.css/animate.min.css
RUN mkdir -p /app/media
EXPOSE 3000
+1 -1
View File
@@ -81,7 +81,7 @@ Primary keys are `id` unless noted otherwise. Timestamps are stored as `created_
### `c_template_regions`
- `id`, `template_id`, `region_key`, `region_type`, `label`, `lock_ratio`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
- `id`, `template_id`, `region_key`, `region_type`, `label`, `font_family`, `lock_ratio`, `animation_json`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by`
- Foreign key:
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
+9 -2
View File
@@ -1,14 +1,15 @@
{
"name": "pulse-signage",
"version": "2.1.0",
"version": "2.5.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "2.0.0",
"version": "2.5.1",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
@@ -755,6 +756,12 @@
"node": ">= 14"
}
},
"node_modules/animate.css": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/animate.css/-/animate.css-4.1.1.tgz",
"integrity": "sha512-+mRmCTv6SbCmtYJCN4faJMNFVNN5EuCTTprDTAo7YzIGji2KADmakjVA3+8mVDkZ2Bf09vayB35lSQIex2+QaQ==",
"license": "MIT"
},
"node_modules/ansi-regex": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
+4 -2
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "2.4.2",
"version": "2.5.2",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
@@ -13,10 +13,12 @@
"start:web": "node -r dotenv/config src/web.js",
"start:player": "node -r dotenv/config src/player.js",
"dev:web": "nodemon -r dotenv/config src/web.js",
"dev:player": "nodemon -r dotenv/config src/player.js"
"dev:player": "nodemon -r dotenv/config src/player.js",
"test": "node --test"
},
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"animate.css": "^4.1.1",
"bootstrap-icons": "1.11.3",
"cropperjs": "^1.6.2",
"dotenv": "^17.4.2",
+61 -2
View File
@@ -1,6 +1,7 @@
// Template data access helpers and region normalization logic.
const { parseJsonSafe, readFormArray } = require('./utils');
const animationPresets = require('../web/public/js/templates/animation-presets');
function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim();
@@ -23,6 +24,61 @@ function normalizeTemplateRegionLockRatio(value) {
return rawRatio.replace(/\s+/g, '');
}
function normalizeTemplateRegionAnimation(value) {
if (animationPresets && typeof animationPresets.normalize === 'function') {
return animationPresets.normalize(value);
}
const allowed = new Set(
Array.isArray(animationPresets && animationPresets.allValues) && animationPresets.allValues.length
? animationPresets.allValues
: ['none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp', 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp', 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp', 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight', 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight', 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce']
);
const raw = String(value || '').trim().toLowerCase();
return allowed.has(raw) ? raw : 'none';
}
function normalizeTemplateRegionAnimationStep(value, fallbackPreset) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
const normalized = {};
Object.keys(value).forEach((key) => {
normalized[key] = value[key];
});
normalized.preset = normalizeTemplateRegionAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
return normalized;
}
return {
preset: normalizeTemplateRegionAnimation(typeof value === 'string' ? value : fallbackPreset || 'none')
};
}
function normalizeTemplateRegionAnimationConfig(value) {
let raw = value;
if (typeof raw === 'string') {
const text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizeTemplateRegionAnimationStep(raw.intro, 'none'),
outro: normalizeTemplateRegionAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizeTemplateRegionAnimationStep(raw.loop, 'none')
};
}
function normalizeTemplateRegionName(value) {
return String(value || '').trim();
}
@@ -57,7 +113,7 @@ async function fetchTemplateById(pool, id) {
return null;
}
const template = templates[0];
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
template.regions = regions;
return template;
}
@@ -70,7 +126,7 @@ async function fetchTemplatesData(pool) {
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
return { templates, templateRegions };
}
@@ -86,6 +142,7 @@ function extractTemplateRegions(body) {
region_type: regionType,
label: String(region.region_name || region.label || region.region_key || '').trim(),
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
x: Number(region.x || 0),
y: Number(region.y || 0),
width: Number(region.width || 100),
@@ -101,6 +158,7 @@ function extractTemplateRegions(body) {
const labels = readFormArray(body, 'region_label[]');
const types = readFormArray(body, 'region_type[]');
const ratios = readFormArray(body, 'region_lock_ratio[]');
const animationJsons = readFormArray(body, 'region_animation_json[]');
const xs = readFormArray(body, 'region_x[]');
const ys = readFormArray(body, 'region_y[]');
const widths = readFormArray(body, 'region_width[]');
@@ -120,6 +178,7 @@ function extractTemplateRegions(body) {
region_type: regionType,
label: name,
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
x: Number(xs[i] || 0),
y: Number(ys[i] || 0),
width: Number(widths[i] || 100),
+1 -1
View File
@@ -5,7 +5,7 @@ async function bootstrapDatabase(pool) {
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
await pool.query(`
INSERT INTO c_canvas_sizes (name, width, height) VALUES
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
('Full HD', 1920, 1080),
('HD', 1280, 720),
('4K UHD', 3840, 2160),
+1
View File
@@ -52,6 +52,7 @@ async function ensureSchema(pool, options) {
label VARCHAR(255) NOT NULL,
font_family VARCHAR(100) NULL,
lock_ratio VARCHAR(20) NULL,
animation_json JSON NULL,
x INT NOT NULL DEFAULT 0,
y INT NOT NULL DEFAULT 0,
width INT NOT NULL DEFAULT 100,
+54 -17
View File
@@ -55,16 +55,7 @@ const VERSIONED_MIGRATIONS = [
}
// Recreate the screen-to-player foreign key after the column exists and legacy data is copied over.
const [screenPlayerFkRows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = 'd_screens'
AND CONSTRAINT_NAME = 'fk_screens_player'`
);
if (Number(screenPlayerFkRows && screenPlayerFkRows[0] && screenPlayerFkRows[0].fk_count) === 0) {
await pool.query('ALTER TABLE d_screens ADD CONSTRAINT fk_screens_player FOREIGN KEY (player_id) REFERENCES d_players(device_id) ON DELETE RESTRICT');
}
await ensureForeignKey(pool, 'd_screens', 'fk_screens_player', 'player_id', 'd_players', 'device_id', 'RESTRICT');
if (await columnExists(pool, 'c_template_regions', 'font_family')) {
await pool.query('ALTER TABLE c_template_regions DROP COLUMN font_family');
@@ -107,9 +98,7 @@ const VERSIONED_MIGRATIONS = [
INDEX idx_announcements_modified_at (modified_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
}
},
{
version: '2.4.0',
@@ -217,6 +206,13 @@ const VERSIONED_MIGRATIONS = [
`);
}
},
{
version: '2.5.1',
label: 'v2.5.1 template animation json schema',
run: async function (pool) {
await ensureColumn(pool, 'c_template_regions', 'animation_json', 'JSON NULL', 'lock_ratio');
}
}
];
async function columnExists(pool, tableName, columnName) {
@@ -238,29 +234,70 @@ async function ensureColumn(pool, tableName, columnName, columnDefinition, after
}
const afterClause = afterColumn ? ' AFTER ' + afterColumn : '';
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
try {
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
} catch (error) {
if (!error || (error.code !== 'ER_DUP_FIELDNAME' && error.errno !== 1060)) {
throw error;
}
}
}
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
const [rows] = await pool.query(
const [existingFkRows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND TABLE_NAME = ?
AND COLUMN_NAME = ?
AND REFERENCED_TABLE_NAME IS NOT NULL`,
[tableName, columnName]
);
if (Number(existingFkRows && existingFkRows[0] && existingFkRows[0].fk_count) > 0) {
return;
}
const [rows] = await pool.query(
`SELECT COUNT(*) AS fk_count
FROM information_schema.KEY_COLUMN_USAGE
WHERE TABLE_SCHEMA = DATABASE()
AND CONSTRAINT_NAME = ?`,
[tableName, constraintName]
[constraintName]
);
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
return;
}
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
const fallbackNames = [
constraintName,
tableName + '_' + columnName + '_fk',
tableName + '_' + columnName + '_fk_2',
tableName + '_' + columnName + '_fk_3'
];
for (const candidateName of fallbackNames) {
try {
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + candidateName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
return;
} catch (error) {
if (!error || (error.code !== 'ER_FK_DUP_NAME' && error.errno !== 1826)) {
throw error;
}
}
}
}
async function dropColumnIfExists(pool, tableName, columnName) {
if (await columnExists(pool, tableName, columnName)) {
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
try {
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
} catch (error) {
if (!error || (error.code !== 'ER_CANT_DROP_FIELD_OR_KEY' && error.errno !== 1091)) {
throw error;
}
}
}
}
@@ -101,6 +101,7 @@
gap: 2.5rem;
min-width: 100%;
width: max-content;
backface-visibility: hidden;
will-change: transform;
animation: lower-third-scroll var(--announcement-scroll-duration, 20s) linear infinite;
}
@@ -144,10 +145,10 @@
@keyframes lower-third-scroll {
from {
transform: translateX(0);
transform: translate3d(0, 0, 0);
}
to {
transform: translateX(-50%);
transform: translate3d(-50%, 0, 0);
}
}
</style>
@@ -103,6 +103,7 @@
gap: 2.5rem;
min-width: 100%;
width: max-content;
backface-visibility: hidden;
will-change: transform;
animation: top-banner-scroll var(--announcement-scroll-duration, 20s) linear infinite;
}
@@ -146,10 +147,10 @@
@keyframes top-banner-scroll {
from {
transform: translateX(0);
transform: translate3d(0, 0, 0);
}
to {
transform: translateX(-50%);
transform: translate3d(-50%, 0, 0);
}
}
</style>
+5 -6
View File
@@ -1,6 +1,10 @@
// Announcement overlay polling and rendering helpers.
(function () {
if (window.__pulseThumbnailPreview) {
return;
}
var announcementLayer = null;
var announcementRefreshTimer = null;
var announcementPollTimer = null;
@@ -211,12 +215,7 @@
return;
}
var socketUrl = new URL(announcementSocketPath, window.location.origin);
if (window.__pulsePageAuthToken) {
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
}
var socket = new WebSocket(socketUrl.toString());
var socket = new WebSocket(new URL(announcementSocketPath, window.location.origin).toString());
announcementSocket = socket;
socket.onopen = function () {
+1
View File
@@ -6,6 +6,7 @@
<title>{{TITLE}}</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" />
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css" />
<link rel="stylesheet" href="/assets/css/player.css?v=36" />
{{{STYLESHEETS}}}
</head>
+1 -1
View File
@@ -121,7 +121,7 @@ function createPlayerPlaylistService(options) {
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id IN (?)
`, [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
[regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]);
templateRows.forEach(function (template) {
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
templatesById[template.id] = template;
+19 -1
View File
@@ -22,6 +22,21 @@ body.onboarding-page #app {
display: none;
}
body.thumbnail-preview *,
body.thumbnail-preview *::before,
body.thumbnail-preview *::after {
animation: none !important;
animation-delay: 0s !important;
animation-duration: 0s !important;
animation-iteration-count: 1 !important;
transition: none !important;
}
body.thumbnail-preview .player-announcement-layer,
body.thumbnail-preview .player-offline-banner {
display: none !important;
}
#app {
width: 100%;
height: 100%;
@@ -199,6 +214,8 @@ body.onboarding-page #app {
height: 100%;
opacity: 0;
transition: opacity 560ms ease;
will-change: opacity;
backface-visibility: hidden;
}
.slide-shell.is-visible {
@@ -228,7 +245,8 @@ body.screen-blackout #app {
height: var(--player-canvas-height, 100vh);
max-width: 100vw;
max-height: 100vh;
transform: translate(-50%, -50%);
transform: translate3d(-50%, -50%, 0);
backface-visibility: hidden;
z-index: 9999;
pointer-events: none;
display: flex;
+79 -14
View File
@@ -6,6 +6,8 @@ function getCurrentViewport() {
};
}
var slideOutroTimers = [];
// Command websocket and player-state helpers.
// Send the current playback state to the command websocket.
function sendCommandState(currentSlide) {
@@ -65,6 +67,56 @@ function clearSlideTimer() {
window.clearTimeout(timer);
timer = null;
}
clearSlideOutroTimer();
}
// Cancel any pending outro triggers for the current slide.
function clearSlideOutroTimer() {
if (!Array.isArray(slideOutroTimers) || !slideOutroTimers.length) {
slideOutroTimers = [];
return;
}
slideOutroTimers.forEach(function (timerId) {
window.clearTimeout(timerId);
});
slideOutroTimers = [];
}
// Return the rendered slide root that is currently on screen.
function getCurrentSlideRoot() {
var shells = Array.prototype.slice.call(app ? app.querySelectorAll('.slide-shell') : []);
if (shells.length) {
return shells[shells.length - 1];
}
return app && app.firstElementChild ? app.firstElementChild : app;
}
// Schedule the outgoing slide animation for each region so it finishes before removal.
function scheduleSlideOutro(holdDelayMs) {
clearSlideOutroTimer();
var currentRoot = getCurrentSlideRoot();
if (!currentRoot || typeof getRegionAnimationPhaseTimings !== 'function' || typeof playRegionAnimation !== 'function') {
return;
}
var regionTimings = getRegionAnimationPhaseTimings(currentRoot, 'outro');
if (!regionTimings.length) {
return;
}
var slideDurationMs = Math.max(1, Math.round(Number(holdDelayMs || 0)));
slideOutroTimers = regionTimings.map(function (entry) {
var timingMs = Math.max(0, Math.round(Number(entry && entry.timingMs || 0)));
var triggerDelayMs = Math.max(0, slideDurationMs - timingMs);
return window.setTimeout(function () {
if (!entry || !entry.element || !entry.element.isConnected) {
return;
}
playRegionAnimation(entry.element, 'outro');
}, triggerDelayMs);
});
}
// Schedule the next slide transition.
@@ -72,10 +124,12 @@ function scheduleSlideAdvance(delayMs) {
clearSlideTimer();
var holdDelayMs = Math.max(1, Number(delayMs || 0));
slideExpiresAt = Date.now() + holdDelayMs;
scheduleSlideOutro(holdDelayMs);
timer = window.setTimeout(function () {
timer = null;
slideExpiresAt = null;
pausedRemainingMs = null;
clearSlideOutroTimer();
const activeSlides = getCurrentActiveSlides();
if (activeSlides.length < 2) {
refresh();
@@ -89,13 +143,10 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs);
}
// Account for fade only when it is enabled so the transition is centered on the slide boundary.
// Return the slide duration without shifting it for fade timing.
function getSlideHoldDelay(delayMs) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
if (!currentPlaylistFadeBetweenSlides) {
return holdDelayMs;
}
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
return holdDelayMs;
}
// Cancel any pending fade-transition cleanup.
@@ -114,11 +165,19 @@ function getPlayerRegionModules() {
return window.pulsePlayerRegionTypes.list();
}
function isThumbnailPreview() {
return Boolean(window.__pulseThumbnailPreview);
}
function runRegionLifecycle(root, lifecycleName) {
if (!root) {
return;
}
if (isThumbnailPreview() && lifecycleName === 'initRegion') {
return;
}
getPlayerRegionModules().forEach(function (entry) {
var module = entry && entry.definition ? entry.definition : null;
if (!module || typeof module[lifecycleName] !== 'function') {
@@ -208,8 +267,15 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
initializeRegionInstances(app);
if (!isThumbnailPreview()) {
initializeRegionInstances(app);
}
initializeRenderedVideoPlayback(app);
if (typeof playRegionAnimations === 'function') {
window.requestAnimationFrame(function () {
playRegionAnimations(app, 'intro');
});
}
return app.firstElementChild;
}
@@ -277,14 +343,18 @@ function renderSlideMarkup(markup, shouldFade) {
window.requestAnimationFrame(function () {
nextShell.style.opacity = '1';
previousShell.style.opacity = '0';
if (typeof playRegionAnimations === 'function') {
playRegionAnimations(nextShell, 'intro');
}
});
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
initializeRegionInstances(nextShell);
if (!isThumbnailPreview()) {
initializeRegionInstances(nextShell);
}
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
slideTransitionTimer = window.setTimeout(function () {
@@ -456,12 +526,7 @@ function connectCommandSocket() {
if (commandSocket && (commandSocket.readyState === WebSocket.OPEN || commandSocket.readyState === WebSocket.CONNECTING)) {
return;
}
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
var socketUrl = new URL(commandSocketPath, window.location.origin);
if (window.__pulsePageAuthToken) {
socketUrl.searchParams.set('auth', window.__pulsePageAuthToken);
}
var socket = new WebSocket(socketUrl.toString());
var socket = new WebSocket(new URL(commandSocketPath, window.location.origin).toString());
commandSocket = socket;
socket.onopen = function () {
+275 -1
View File
@@ -53,6 +53,276 @@ function fitCanvasSize(canvasWidth, canvasHeight, maxWidth, maxHeight) {
};
}
function normalizePlayerAnimationStep(value, fallbackPreset) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return {
preset: String(value.preset || fallbackPreset || 'none').trim(),
duration_ms: Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null,
delay_ms: Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null,
iterations: Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null
};
}
return {
preset: String(typeof value === 'string' ? value : fallbackPreset || 'none').trim(),
duration_ms: null,
delay_ms: null,
iterations: null
};
}
function normalizePlayerAnimationConfig(value) {
var raw = value;
if (typeof raw === 'string') {
var text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizePlayerAnimationStep(raw.intro, 'none'),
outro: normalizePlayerAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizePlayerAnimationStep(raw.loop, 'none')
};
}
function hasPlayerAnimation(config) {
return Boolean(config && ['intro', 'outro', 'loop'].some(function (stepName) {
return String((config[stepName] && config[stepName].preset) || '').trim() && String((config[stepName] && config[stepName].preset) || '').trim() !== '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 clearRegionAnimationClasses(root) {
if (!root) {
return;
}
var elements = [];
if (typeof root.matches === 'function' && root.matches('[data-animation-json]')) {
elements.push(root);
}
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
elements.push(element);
});
elements.forEach(function (element) {
if (!element) {
return;
}
element.classList.remove('animate__animated', 'animate__infinite');
element.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
Array.prototype.slice.call(element.classList || []).forEach(function (className) {
if (String(className || '').indexOf('animate__') === 0) {
element.classList.remove(className);
}
});
element.style.removeProperty('--animate-duration');
element.style.removeProperty('--animate-delay');
element.style.removeProperty('--animate-repeat');
});
}
function isAttentionSeekerAnimation(preset) {
return ['bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat'].indexOf(String(preset || '').trim()) !== -1;
}
function applyAnimationStep(element, step, phase) {
if (!element || !step) {
return;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return;
}
element.classList.add('animate__animated', 'animate__' + preset);
element.style.setProperty('--animate-duration', String(Math.max(1, Number(step.duration_ms || 0) || 1000)) + 'ms');
if (Number(step.delay_ms || 0) > 0) {
element.style.setProperty('--animate-delay', String(Math.max(0, Number(step.delay_ms || 0))) + 'ms');
} else {
element.style.removeProperty('--animate-delay');
}
if (phase === 'loop') {
var repeatCount = Number(step.iterations);
if (!Number.isFinite(repeatCount) || repeatCount < 1) {
repeatCount = 1;
}
if (repeatCount > 1) {
element.classList.add('animate__repeat-1');
}
element.style.setProperty('--animate-repeat', String(repeatCount));
return;
}
if (isAttentionSeekerAnimation(preset) && Number(step.iterations || 0) > 1) {
element.style.setProperty('--animate-repeat', String(Math.max(1, Number(step.iterations || 1))));
}
}
function getAnimationStepTimingMs(step) {
if (!step) {
return 0;
}
var preset = String(step.preset || 'none').trim();
if (!preset || preset === 'none') {
return 0;
}
var durationMs = Number(step.duration_ms);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
durationMs = 1000;
}
var delayMs = Number(step.delay_ms);
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
var iterations = Number(step.iterations);
if (!Number.isFinite(iterations) || iterations < 1) {
iterations = 1;
}
return delayMs + (durationMs * iterations);
}
function getRegionAnimationPhaseTimingMs(root, phase) {
var timings = getRegionAnimationPhaseTimings(root, phase);
return timings.reduce(function (maxTimingMs, entry) {
return Math.max(maxTimingMs, Number(entry && entry.timingMs || 0));
}, 0);
}
function getRegionAnimationPhaseTimings(root, phase) {
if (!root || isThumbnailPreview()) {
return [];
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var timings = [];
Array.prototype.forEach.call(root.querySelectorAll('[data-animation-json]'), function (element) {
if (!element) {
return;
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
var timingMs = getAnimationStepTimingMs(normalizedPhase === 'outro' ? config.outro : config.intro);
if (timingMs > 0) {
timings.push({
element: element,
timingMs: timingMs
});
}
});
return timings;
}
function playRegionAnimation(element, phase) {
if (!element || isThumbnailPreview()) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var config;
try {
config = normalizePlayerAnimationConfig(element.getAttribute('data-animation-json') || '{}');
} catch (_error) {
config = null;
}
if (!config) {
return;
}
element.dataset.animationPhase = normalizedPhase;
clearRegionAnimationClasses(element);
if (normalizedPhase === 'outro') {
applyAnimationStep(element, config.outro, 'outro');
return;
}
if (config.intro && String(config.intro.preset || '').trim() && String(config.intro.preset || '').trim() !== 'none') {
applyAnimationStep(element, config.intro, 'intro');
if (config.loop && String(config.loop.preset || '').trim() && String(config.loop.preset || '').trim() !== 'none') {
element.addEventListener('animationend', function handleAnimationEnd(event) {
if (event.target !== element) {
return;
}
if (String(element.dataset.animationPhase || '').trim() !== 'intro') {
return;
}
element.removeEventListener('animationend', handleAnimationEnd);
clearRegionAnimationClasses(element);
applyAnimationStep(element, config.loop, 'loop');
});
}
return;
}
applyAnimationStep(element, config.loop, 'loop');
}
function playRegionAnimations(root, phase) {
if (!root || isThumbnailPreview()) {
return;
}
var normalizedPhase = String(phase || 'intro').trim().toLowerCase();
if (normalizedPhase !== 'intro' && normalizedPhase !== 'outro') {
normalizedPhase = 'intro';
}
var elements = Array.prototype.slice.call(root.querySelectorAll('[data-animation-json]'));
elements.forEach(function (element) {
playRegionAnimation(element, normalizedPhase);
});
}
function setPlayerCanvasDimensions(canvasWidth, canvasHeight) {
if (!document || !document.documentElement) {
return;
@@ -474,6 +744,7 @@ function getTemplateLayout(template) {
regionKey: region.region_key,
regionType: region.region_type,
label: region.label,
animationJson: region.animation_json || null,
baseStyle: baseStyle,
pixelWidth: pixelWidth,
pixelHeight: pixelHeight,
@@ -578,7 +849,10 @@ function renderTemplateSlideMarkup(slide) {
const layout = plan ? plan.layout : null;
const regions = layout ? layout.regions.map(function (region) {
const regionContent = content[region.regionKey] || {};
return plan.renderRegion(region, regionContent);
const markup = plan.renderRegion(region, regionContent);
return decorateRegionMarkup(markup, {
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
});
}).join('') : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
if (layout) {
File diff suppressed because one or more lines are too long
+3 -1
View File
@@ -120,6 +120,7 @@ function renderPlayerPage(slug, initialData) {
const hlsScriptTag = '<script src="/assets/vendor/hls.min.js"></script>';
const pageAuthToken = createPageAuthBundle({ scope: 'player', slug: String(slug || '').trim() });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
const bodyClass = [initialData && initialData.thumbnailPreview ? 'thumbnail-preview' : '', ''].join(' ').trim();
const script = getPlayerPageScript()({
SLUG_JSON: new Handlebars.SafeString(JSON.stringify(slug)),
INITIAL_DATA_JSON: new Handlebars.SafeString(safeJsonForScript(initialData || null)),
@@ -128,9 +129,10 @@ function renderPlayerPage(slug, initialData) {
return renderPage(template, {
title: 'Screen ' + slug,
bodyClass: bodyClass,
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
script: createPageFetchAuthScript(pageAuthToken, '/ws/screens/' + encodeURIComponent(slug || '')) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + '<script>' + getAnnouncementIconsDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesDataScript() + '</script>' + '<script>' + getPlayerAnnouncementTemplatesScript() + '</script>' + onboardingScript + script + '<script>' + getPlayerPageAnnouncementsScript()() + '</script>'
});
}
+31 -2
View File
@@ -4,6 +4,7 @@ const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const { isClientNameAvailable } = require('#src/data/client-name-check');
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
const PAGE_AUTH_COOKIE_NAME = 'pulse_page_auth';
function normalizePlayerPublicBaseUrl(pageUrl) {
const value = String(pageUrl || '').trim();
@@ -43,6 +44,34 @@ function createPlayerRuntime(options) {
return ip;
}
function parseCookies(cookieHeader) {
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
if (!pair) {
return cookies;
}
const separatorIndex = pair.indexOf('=');
if (separatorIndex === -1) {
return cookies;
}
const name = decodeURIComponent(pair.slice(0, separatorIndex).trim());
const value = decodeURIComponent(pair.slice(separatorIndex + 1).trim());
if (name) {
cookies[name] = value;
}
return cookies;
}, {});
}
function readPageAuthToken(request) {
const queryToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
if (queryToken) {
return queryToken;
}
const cookies = parseCookies(request.headers && request.headers.cookie || '');
return String(cookies[PAGE_AUTH_COOKIE_NAME] || '').trim();
}
function getConnectionBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
@@ -314,7 +343,7 @@ function createPlayerRuntime(options) {
}
if (playerMatch) {
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
const authToken = readPageAuthToken(request);
const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy();
@@ -323,7 +352,7 @@ function createPlayerRuntime(options) {
}
if (announcementMatch) {
const authToken = String(new URL(request.url, 'http://localhost').searchParams.get('auth') || '').trim();
const authToken = readPageAuthToken(request);
const payload = verifyPageAuthToken(authToken);
if (!payload || String(payload.scope || '').trim() !== 'player') {
socket.destroy();
+22 -1
View File
@@ -246,12 +246,13 @@ function verifyRequestAuth(req) {
return timingSafeEqualHex(expectedSignature, signature);
}
function createPageFetchAuthScript(token) {
function createPageFetchAuthScript(token, cookiePath) {
const normalizedToken = String(token && typeof token === 'object' ? token.token : token || '').trim();
if (!normalizedToken) {
return '';
}
const normalizedCookiePath = String(cookiePath || '').trim();
const pageAuthExpiresAt = token && typeof token === 'object' && Number.isFinite(Number(token.expiresAt))
? Number(token.expiresAt)
: null;
@@ -262,6 +263,8 @@ function createPageFetchAuthScript(token) {
' (function () {',
' var pageAuthToken = ' + JSON.stringify(normalizedToken) + ';',
' var pageAuthExpiresAt = ' + JSON.stringify(pageAuthExpiresAt) + ';',
' var pageAuthCookiePath = ' + JSON.stringify(normalizedCookiePath) + ';',
' var pageAuthCookieName = "pulse_page_auth";',
' var pageAuthRenewalTimer = null;',
' var pageAuthRenewalInFlight = null;',
' var pageAuthRenewalSkewMs = ' + JSON.stringify(renewSkewMs) + ';',
@@ -287,11 +290,28 @@ function createPageFetchAuthScript(token) {
' });',
' }, delayMs);',
' }',
' function writePageAuthCookie(nextToken, nextExpiresAt) {',
' if (!pageAuthCookiePath) {',
' return;',
' }',
' var cookieParts = [pageAuthCookieName + "=" + encodeURIComponent(String(nextToken || "").trim()), "Path=" + pageAuthCookiePath, "SameSite=Lax"];',
' var expiresInMs = Number(nextExpiresAt || 0) - Date.now();',
' if (Number.isFinite(expiresInMs) && expiresInMs > 0) {',
' cookieParts.push("Max-Age=" + Math.max(1, Math.floor(expiresInMs / 1000)));',
' } else {',
' cookieParts.push("Max-Age=0");',
' }',
' if (window.location.protocol === "https:") {',
' cookieParts.push("Secure");',
' }',
' document.cookie = cookieParts.join("; ");',
' }',
' function setPageAuthToken(nextToken, nextExpiresAt) {',
' pageAuthToken = String(nextToken || "").trim();',
' pageAuthExpiresAt = Number(nextExpiresAt || 0) || null;',
' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' schedulePageAuthRenewal();',
' }',
' async function renewPageAuthToken() {',
@@ -326,6 +346,7 @@ function createPageFetchAuthScript(token) {
' window.__pulseRenewPageAuthToken = renewPageAuthToken;',
' window.__pulsePageAuthToken = pageAuthToken;',
' window.__pulsePageAuthExpiresAt = pageAuthExpiresAt;',
' writePageAuthCookie(pageAuthToken, pageAuthExpiresAt);',
' window.addEventListener("focus", function () {',
' schedulePageAuthRenewal();',
' });',
+1
View File
@@ -160,6 +160,7 @@ function registerPartials(Handlebars, viewsRoot) {
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
Handlebars.registerPartial('signage/templates/animation-advanced-modal', fs.readFileSync(path.join(viewsRoot, 'signage', 'templates', 'animation-advanced-modal.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
}
+107 -15
View File
@@ -190,6 +190,82 @@
max-width: 14rem;
}
.template-preview-card .card-body {
gap: 0.75rem 1rem;
}
.template-preview-card.is-previewing {
border-color: rgba(var(--bs-primary-rgb), 0.45);
}
.template-preview-card.is-previewing .card-header {
position: relative;
}
.template-preview-card.is-previewing .card-title {
position: relative;
padding-right: 6.25rem;
}
.template-preview-card.is-previewing .card-title::after {
content: ' previewing';
position: absolute;
top: 50%;
left: calc(100% + 0.35rem);
color: var(--bs-primary);
font-size: 0.8em;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.04em;
white-space: nowrap;
transform: translateY(-50%);
}
.template-preview-card .btn-group {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.5rem;
}
.template-preview-card .btn-group .btn {
width: 100%;
}
.template-preview-card[hidden] {
display: none !important;
}
.template-designer-form--previewing .designer-stage {
box-shadow: none !important;
overflow: hidden;
}
.template-designer-form--previewing .designer-overlay {
cursor: progress;
}
.template-designer-form--previewing .designer-rect {
transition: none !important;
}
.template-designer-form--previewing .resize-handle {
display: none;
}
.template-designer-form--previewing .region-item,
.template-designer-form--previewing .template-details-card,
.template-designer-form--previewing .template-options-card,
.template-designer-form--previewing .region-info-card {
opacity: 0.84;
}
.template-designer-form--previewing .region-item [disabled],
.template-designer-form--previewing .template-details-card [disabled],
.template-designer-form--previewing .template-options-card [disabled],
.template-designer-form--previewing .region-info-card [disabled] {
cursor: not-allowed;
}
.announcement-icon-picker {
position: relative;
}
@@ -1401,7 +1477,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
position: relative;
width: 100%;
min-height: 18rem;
/* overflow: hidden; */
overflow: visible;
border: 1px solid var(--bs-border-color);
border-radius: 0;
background: var(--bs-body-bg);
@@ -1431,11 +1507,16 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.designer-overlay {
z-index: 2;
cursor: default;
--designer-overlay-scale: 1;
}
.designer-rect {
position: absolute;
border: 2px solid rgba(13, 110, 253, 0.95);
overflow: visible;
z-index: 3;
border: 0;
outline: 2px solid rgba(13, 110, 253, 0.95);
outline-offset: -2px;
background: rgba(13, 110, 253, 0.12);
box-sizing: border-box;
border-radius: 0;
@@ -1445,20 +1526,20 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
}
.designer-rect.selected {
border-color: rgba(25, 135, 84, 1);
outline-color: rgba(25, 135, 84, 1);
background: rgba(25, 135, 84, 0.14);
}
.designer-rect-label {
position: absolute;
left: 0.4rem;
top: 0.35rem;
left: calc(6px * var(--designer-overlay-scale, 1));
top: calc(6px * var(--designer-overlay-scale, 1));
max-width: 100%;
padding: 0.15rem 0.45rem;
padding: calc(2px * var(--designer-overlay-scale, 1)) calc(7px * var(--designer-overlay-scale, 1));
border-radius: 999px;
background: rgba(33, 37, 41, 0.92);
color: #fff;
font-size: 0.75rem;
font-size: calc(0.75rem * var(--designer-overlay-scale, 1));
line-height: 1.2;
white-space: nowrap;
overflow: hidden;
@@ -1471,8 +1552,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.resize-handle {
position: absolute;
width: 0.8rem;
height: 0.8rem;
z-index: 4;
width: 12px;
height: 12px;
box-sizing: border-box;
border-radius: 999px;
border: 2px solid #fff;
background: rgba(13, 110, 253, 1);
@@ -1482,28 +1565,28 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.resize-handle.nw {
left: 0;
top: 0;
transform: translate(-50%, -50%);
transform: translate(-40%, -40%);
cursor: nwse-resize;
}
.resize-handle.ne {
right: 0;
top: 0;
transform: translate(50%, -50%);
transform: translate(40%, -40%);
cursor: nesw-resize;
}
.resize-handle.sw {
left: 0;
bottom: 0;
transform: translate(-50%, 50%);
transform: translate(-40%, 40%);
cursor: nesw-resize;
}
.resize-handle.se {
right: 0;
bottom: 0;
transform: translate(50%, 50%);
transform: translate(40%, 40%);
cursor: nwse-resize;
}
@@ -1641,6 +1724,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.region-field-grid--animation {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.region-field-grid > label {
display: grid;
gap: 0.35rem;
@@ -1656,6 +1743,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
display: flex;
align-items: center;
justify-content: space-between;
flex-wrap: nowrap;
gap: 0;
min-height: 3.25rem;
}
@@ -1669,6 +1757,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.template-field-head strong {
font-size: 0.95rem;
min-width: 0;
}
.template-field-head .chip {
@@ -1782,6 +1871,7 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
display: grid;
gap: 0.5rem;
padding-top: 0.25rem;
margin-bottom: 0.75rem;
}
.api-region-placeholder-title {
@@ -1793,7 +1883,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
}
.api-region-sample-accordion {
padding: 0.85rem 1rem;
padding: 0.85rem 1rem 1rem;
margin-bottom: 0.75rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.85rem;
background: var(--bs-secondary-bg);
@@ -1877,7 +1968,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
.region-field-grid--identity,
.region-field-grid--position,
.region-field-grid--size {
.region-field-grid--size,
.region-field-grid--animation {
grid-template-columns: 1fr;
}
}
+2 -1
View File
@@ -191,6 +191,7 @@
var region = context.region;
var current = context.current || {};
var config = context.config || {};
var placeholderFields = Array.isArray(context.placeholderFields) ? context.placeholderFields : [];
var placeholderChips = String(context.placeholderChips || '');
var sourceOptions = String(context.sourceOptions || '');
var itemsPathValue = context.itemsPath !== undefined
@@ -234,7 +235,7 @@
'<div class="muted slide-image-file">Use transforms like {{name.upper()}}, {{name.title()}}, or {{name.lower()}} on leaf fields.</div>' +
'<div class="api-region-placeholder-section">' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
'</div>' +
'<details class="api-region-sample-accordion" data-api-sample-data-accordion>' +
'<summary>Data</summary>' +
+6 -3
View File
@@ -218,7 +218,7 @@
'<span class="chip">RSS</span>' +
'</div>' +
'</div>' +
'<div class="card-body p-3 d-grid">' +
'<div class="card-body p-3 d-grid gap-3 pb-0">' +
'<div class="editor-holder" data-region-id="' + region.id + '">' +
'<textarea class="editor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
'</div>' +
@@ -237,8 +237,11 @@
'</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
'<div class="muted slide-image-file">Use placeholders like {{title.upper()}}, {{title.title()}}, or {{title.lower()}}. Available placeholders:</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
'<div class="muted slide-image-file">Use transforms like {{title.upper()}}, {{title.title()}}, or {{title.lower()}} on leaf fields.</div>' +
'<div class="api-region-placeholder-section">' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
'</div>' +
'</div>' +
'</div>';
}
+1 -1
View File
@@ -21,7 +21,7 @@
function renderEditorCard(context) {
var region = context.region;
var current = String(context.current || '');
var disableAudio = context.disableAudio === undefined ? true : Boolean(context.disableAudio);
var disableAudio = context.disableAudio === undefined ? false : Boolean(context.disableAudio);
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card-header template-field-head">' +
@@ -233,7 +233,7 @@ export function createSlideFormEditorController(options) {
promotion: false,
statusbar: true,
resize: true,
plugins: 'lists link code advlist fullscreen table',
plugins: 'lists code advlist fullscreen table',
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist table | fullscreen',
toolbar_mode: 'sliding',
license_key: 'gpl',
@@ -244,6 +244,7 @@ export function createSlideFormEditorController(options) {
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
font_family_formats: getFontFamilyFormats(),
font_size_input_default_unit: 'px',
invalid_elements: 'a',
forced_root_block: 'p',
force_br_newlines: false,
newline_behavior: 'default',
+21 -4
View File
@@ -200,7 +200,16 @@ export function createSlideFormRegionHelpers(options) {
function updateRssPlaceholderChips(regionId, feedId) {
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
updatePlaceholderChipList(card, getRssFieldList(feedId), 'No RSS fields available.');
if (!card) {
return;
}
var container = card.querySelector('[data-placeholder-chips]');
if (!container) {
return;
}
container.innerHTML = buildLimitedPlaceholderChipMarkup(getRssFieldList(feedId), 'No RSS fields available.');
}
function getCurrentRssConfig(region) {
@@ -502,6 +511,11 @@ export function createSlideFormRegionHelpers(options) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
placeholderFields: region.region_type === 'api'
? getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path)
: region.region_type === 'rss'
? getRssFieldList(getCurrentRssConfig(region).feed_id)
: [],
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
timetableGroups: timetableGroups
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
@@ -533,10 +547,13 @@ export function createSlideFormRegionHelpers(options) {
return '<option value="' + escapeHtml(source.id) + '"' + selected + '>' + escapeHtml(source.name || ('Source ' + source.id)) + '</option>';
}).join(''),
placeholderChips: region.region_type === 'rss'
? getRssFieldList(rssConfig.feed_id).map(function (field) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
? buildLimitedPlaceholderChipMarkup(getRssFieldList(rssConfig.feed_id), 'No RSS fields available.')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
placeholderFields: region.region_type === 'api'
? getApiFieldList(apiConfig.source_id, apiItemsPath)
: region.region_type === 'rss'
? getRssFieldList(rssConfig.feed_id)
: [],
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
timetableGroups: timetableGroups
};
@@ -0,0 +1,203 @@
(function (root, factory) {
var presets = factory();
if (typeof module === 'object' && module.exports) {
module.exports = presets;
}
if (root) {
root.templateAnimationPresets = presets;
}
})(typeof window !== 'undefined' ? window : (typeof globalThis !== 'undefined' ? globalThis : this), function () {
function option(value, label) {
return { value: value, label: label };
}
function formatLabel(value) {
return String(value || '')
.replace(/([a-z0-9])([A-Z])/g, '$1 $2')
.replace(/[_-]+/g, ' ')
.replace(/\s+/g, ' ')
.trim()
.replace(/\b\w/g, function (letter) {
return letter.toUpperCase();
});
}
function buildAllValues(groups, extraValues) {
var values = [];
var seen = Object.create(null);
function addEntry(entry) {
if (!entry || typeof entry !== 'object' || !entry.value || seen[entry.value]) {
return;
}
seen[entry.value] = true;
values.push(entry.value);
}
Object.keys(groups).forEach(function (groupName) {
(groups[groupName] || []).forEach(function (entry) {
if (Array.isArray(entry)) {
entry.forEach(addEntry);
return;
}
addEntry(entry);
});
});
(extraValues || []).forEach(function (value) {
if (!value || seen[value]) {
return;
}
seen[value] = true;
values.push(value);
});
return values;
}
function buildLookup(values) {
var lookup = Object.create(null);
values.forEach(function (value) {
var key = String(value || '').trim().toLowerCase();
if (key && !lookup[key]) {
lookup[key] = value;
}
});
return lookup;
}
function normalizeValue(value, lookup) {
var raw = String(value || '').trim();
if (!raw) {
return 'none';
}
return lookup[raw.toLowerCase()] || 'none';
}
function splitAnimateCssValues(values) {
var groups = {
intro: [],
outro: [],
attentionSeekers: []
};
(values || []).forEach(function (value) {
if (!value || value === 'none') {
return;
}
if (value.indexOf('In') !== -1) {
groups.intro.push(value);
return;
}
if (value.indexOf('Out') !== -1) {
groups.outro.push(value);
return;
}
groups.attentionSeekers.push(value);
});
return groups;
}
function buildCuratedBasic(values) {
return [option('none', 'None')].concat(values.map(function (value) {
return option(value, formatLabel(value));
}));
}
var animateCssValues = [
'none',
'bounce', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat',
'backInDown', 'backInLeft', 'backInRight', 'backInUp',
'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp',
'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
'fadeIn', 'fadeInDown', 'fadeInDownBig', 'fadeInLeft', 'fadeInLeftBig', 'fadeInRight', 'fadeInRightBig', 'fadeInUp', 'fadeInUpBig', 'fadeInTopLeft', 'fadeInTopRight', 'fadeInBottomLeft', 'fadeInBottomRight',
'fadeOut', 'fadeOutDown', 'fadeOutDownBig', 'fadeOutLeft', 'fadeOutLeftBig', 'fadeOutRight', 'fadeOutRightBig', 'fadeOutUp', 'fadeOutUpBig', 'fadeOutTopLeft', 'fadeOutTopRight', 'fadeOutBottomLeft', 'fadeOutBottomRight',
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY',
'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight',
'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
'hinge', 'jackInTheBox', 'rollIn', 'rollOut',
'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp',
'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp',
'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp'
];
var splitValues = splitAnimateCssValues(animateCssValues);
var basic = {
intro: [
option('none', 'None'),
option('fadeIn', formatLabel('fadeIn')),
option('fadeInDown', formatLabel('fadeInDown')),
option('fadeInLeft', formatLabel('fadeInLeft')),
option('fadeInRight', formatLabel('fadeInRight')),
option('fadeInUp', formatLabel('fadeInUp')),
option('zoomIn', formatLabel('zoomIn')),
option('zoomInDown', formatLabel('zoomInDown')),
option('zoomInLeft', formatLabel('zoomInLeft')),
option('zoomInRight', formatLabel('zoomInRight')),
option('zoomInUp', formatLabel('zoomInUp')),
option('slideInDown', formatLabel('slideInDown')),
option('slideInLeft', formatLabel('slideInLeft')),
option('slideInRight', formatLabel('slideInRight')),
option('slideInUp', formatLabel('slideInUp'))
],
outro: [
option('none', 'None'),
option('fadeOut', formatLabel('fadeOut')),
option('fadeOutDown', formatLabel('fadeOutDown')),
option('fadeOutLeft', formatLabel('fadeOutLeft')),
option('fadeOutRight', formatLabel('fadeOutRight')),
option('fadeOutUp', formatLabel('fadeOutUp')),
option('zoomOut', formatLabel('zoomOut')),
option('zoomOutDown', formatLabel('zoomOutDown')),
option('zoomOutLeft', formatLabel('zoomOutLeft')),
option('zoomOutRight', formatLabel('zoomOutRight')),
option('zoomOutUp', formatLabel('zoomOutUp')),
option('slideOutDown', formatLabel('slideOutDown')),
option('slideOutLeft', formatLabel('slideOutLeft')),
option('slideOutRight', formatLabel('slideOutRight')),
option('slideOutUp', formatLabel('slideOutUp'))
],
attentionSeekers: buildCuratedBasic(['flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'])
};
var advanced = {
intro: buildCuratedBasic(splitValues.intro),
outro: buildCuratedBasic(splitValues.outro),
attentionSeekers: buildCuratedBasic(splitValues.attentionSeekers)
};
var lookup = buildLookup(buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }));
function normalize(value) {
return normalizeValue(value, lookup);
}
function isAnimateCssPreset(value) {
return animateCssValues.indexOf(normalizeValue(value, buildLookup(animateCssValues))) !== -1;
}
return {
basic: basic,
advanced: advanced,
animateCssValues: animateCssValues,
allValues: buildAllValues({ basic: [basic.intro, basic.outro, basic.attentionSeekers], advanced: [advanced.intro, advanced.outro, advanced.attentionSeekers] }),
normalize: normalize,
isAnimateCssPreset: isAnimateCssPreset
};
});
@@ -82,6 +82,174 @@
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function getAnimationConfig(card) {
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
}
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
}
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
}
return animationConfig;
}
function getAnimationPresetValues() {
var presets = window.templateAnimationPresets || {};
if (Array.isArray(presets.allValues) && presets.allValues.length) {
return presets.allValues;
}
return [
'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
];
}
function getAnimationPresetMap() {
var lookup = {};
getAnimationPresetValues().forEach(function (value) {
lookup[value] = true;
});
return lookup;
}
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
if (!selectInput) {
return;
}
var normalizedPreset = normalizePreviewAnimation(preset);
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
return option && option.value === normalizedPreset;
});
if (hasMatchingOption) {
delete selectInput.dataset.animationPresetCustom;
selectInput.hidden = false;
if (resetButton) {
resetButton.hidden = true;
}
} else {
selectInput.dataset.animationPresetCustom = '1';
selectInput.hidden = true;
if (resetButton) {
resetButton.hidden = false;
resetButton.textContent = 'Reset to basic';
}
}
}
function writeAnimationConfig(card, animationConfig) {
var normalized = normalizeAnimationConfig(animationConfig);
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
if (hiddenAnimationInput) {
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
}
if (introPresetInput) {
introPresetInput.value = normalized.intro.preset;
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
}
if (outroPresetInput) {
outroPresetInput.value = normalized.outro.preset;
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
}
if (loopPresetInput) {
loopPresetInput.value = normalized.loop.preset;
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
}
return normalized;
}
function normalizePreviewAnimation(value) {
if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
return window.templateAnimationPresets.normalize(value);
}
var allowed = getAnimationPresetMap();
var raw = String(value || '').trim().toLowerCase();
return allowed[raw] ? raw : 'none';
}
function normalizeAnimationStep(value, fallbackPreset) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
var normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
normalized.easing = String(value.easing || '').trim();
normalized.fill_mode = String(value.fill_mode || '').trim();
return normalized;
}
return {
preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
duration_ms: null,
delay_ms: null,
iterations: null,
easing: '',
fill_mode: ''
};
}
function normalizeAnimationConfig(value) {
var raw = value;
if (typeof raw === 'string') {
var text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizeAnimationStep(raw.intro, 'none'),
outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizeAnimationStep(raw.loop, 'none')
};
}
function formatAnimationConfig(value) {
return JSON.stringify(normalizeAnimationConfig(value), null, 2);
}
function syncRegionIdentity(card, value) {
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
@@ -96,6 +264,7 @@
label: name,
region_type: card.querySelector('[name="region_type[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
animation_json: getAnimationConfig(card),
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
@@ -114,6 +283,7 @@
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -171,6 +341,10 @@
syncRegionIdentity: syncRegionIdentity,
readCard: readCard,
writeCard: writeCard,
normalizeAnimationConfig: normalizeAnimationConfig,
formatAnimationConfig: formatAnimationConfig,
getAnimationConfig: getAnimationConfig,
writeAnimationConfig: writeAnimationConfig,
clampRegion: clampRegion,
getOverlayRect: getOverlayRect,
toCanvasPoint: toCanvasPoint,
@@ -20,6 +20,10 @@
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
var previewCard = document.getElementById('template-preview-card');
var previewIntroButton = document.getElementById('template-preview-intro');
var previewOutButton = document.getElementById('template-preview-out');
var previewContinuousButton = document.getElementById('template-preview-continuous');
var regionList = document.getElementById('region-list');
var regionSelect = document.getElementById('region-select');
var canvasSizeSelect = document.getElementById('canvas-size-select');
@@ -36,6 +40,7 @@
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionAddOptions = document.getElementById('region-add-options');
var animationAdvancedModal = document.getElementById('animation-advanced-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
@@ -43,7 +48,20 @@
var regionUsageSet = new Set();
var draft = null;
var selectedIndex = -1;
var activeAnimationCard = null;
var overlayRenderFrame = 0;
var previewState = {
active: false,
mode: '',
stopTimer: 0,
token: 0,
animations: []
};
var previewButtons = [
{ button: previewIntroButton, mode: 'intro' },
{ button: previewOutButton, mode: 'out' },
{ button: previewContinuousButton, mode: 'continuous' }
];
try {
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
@@ -218,6 +236,7 @@
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value),
animation_json: getAnimationConfig(card),
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
@@ -226,6 +245,582 @@
};
}
function hasAnyAnimationCards() {
return getCards().some(function (card) {
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
return animationConfig && ['intro', 'outro', 'loop'].some(function (stepName) {
var step = animationConfig[stepName] || {};
return normalizePreviewAnimation(step.preset) !== 'none';
});
});
}
function hasPreviewAnimationForMode(mode) {
var previewMode = String(mode || '').trim().toLowerCase();
if (!previewMode) {
return false;
}
return getCards().some(function (card) {
var step = getPreviewRegionAnimationStep(readCard(card), previewMode);
return normalizePreviewAnimation(step && step.preset) !== 'none';
});
}
function getPreviewRects() {
if (!overlay) {
return [];
}
return Array.prototype.slice.call(overlay.querySelectorAll('.designer-rect'));
}
function clearPreviewAnimations() {
previewState.animations.forEach(function (animation) {
if (animation && typeof animation.cancel === 'function') {
animation.cancel();
}
});
previewState.animations = [];
getPreviewRects().forEach(function (rect) {
rect.classList.remove('animate__animated', 'animate__infinite');
rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
if (window.templateAnimationPresets && Array.isArray(window.templateAnimationPresets.animateCssValues)) {
window.templateAnimationPresets.animateCssValues.forEach(function (value) {
if (value && value !== 'none') {
rect.classList.remove('animate__' + value);
}
});
}
rect.style.removeProperty('--animate-duration');
rect.style.removeProperty('--animate-delay');
rect.style.removeProperty('--animate-repeat');
rect.style.opacity = '';
rect.style.transform = '';
rect.style.filter = '';
rect.style.willChange = '';
});
}
function updatePreviewControlState() {
var activeMode = previewState.active ? previewState.mode : '';
previewButtons.forEach(function (entry) {
if (!entry.button) {
return;
}
var available = hasPreviewAnimationForMode(entry.mode);
var disabled = !available || (previewState.active && activeMode !== entry.mode);
entry.button.disabled = disabled;
entry.button.classList.toggle('active', previewState.active && activeMode === entry.mode);
entry.button.setAttribute('aria-pressed', previewState.active && activeMode === entry.mode ? 'true' : 'false');
});
}
function normalizePreviewAnimation(value) {
if (window.templateAnimationPresets && typeof window.templateAnimationPresets.normalize === 'function') {
return window.templateAnimationPresets.normalize(value);
}
var allowed = {};
var presets = window.templateAnimationPresets || {};
var values = Array.isArray(presets.allValues) && presets.allValues.length ? presets.allValues : [
'none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown',
'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp',
'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown',
'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp',
'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp',
'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp',
'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight',
'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight',
'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce'
];
values.forEach(function (valueName) {
allowed[valueName] = true;
});
var raw = String(value || '').trim().toLowerCase();
return allowed[raw] ? raw : 'none';
}
function isAnimateCssPreset(value) {
var presets = window.templateAnimationPresets || {};
if (Array.isArray(presets.animateCssValues) && presets.animateCssValues.length) {
return presets.animateCssValues.indexOf(normalizePreviewAnimation(value)) !== -1;
}
return false;
}
function applyAnimateCssPreview(rect, preset, step, mode) {
var animateClass = 'animate__' + preset;
var durationMs = Number(step && step.duration_ms);
var delayMs = Number(step && step.delay_ms);
var repeatCount = Number(step && step.iterations);
var isAttentionSeeker = preset === 'bounce' || preset === 'flash' || preset === 'pulse' || preset === 'rubberBand' || preset === 'shakeX' || preset === 'shakeY' || preset === 'headShake' || preset === 'swing' || preset === 'tada' || preset === 'wobble' || preset === 'jello' || preset === 'heartBeat';
rect.classList.remove('animate__animated', 'animate__infinite');
rect.style.removeProperty('--animate-duration');
rect.style.removeProperty('--animate-delay');
rect.style.removeProperty('--animate-repeat');
rect.style.setProperty('--animate-duration', Math.max(0, Number.isFinite(durationMs) && durationMs > 0 ? durationMs : 1000) + 'ms');
if (Number.isFinite(delayMs) && delayMs > 0) {
rect.style.setProperty('--animate-delay', Math.max(0, delayMs) + 'ms');
}
rect.classList.add('animate__animated', animateClass);
if (isAttentionSeeker) {
var normalizedRepeatCount = Math.max(1, Number.isFinite(repeatCount) && repeatCount > 0 ? repeatCount : 1);
rect.classList.remove('animate__repeat-1', 'animate__repeat-2', 'animate__repeat-3');
rect.classList.add('animate__repeat-1');
rect.style.setProperty('--animate-repeat', String(normalizedRepeatCount));
}
}
function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) {
var normalizedMode = String(mode || '').trim().toLowerCase();
var durationMs = Number(step && step.duration_ms);
var delayMs = Number(step && step.delay_ms);
var iterations = Number(step && step.iterations);
if (!Number.isFinite(durationMs) || durationMs <= 0) {
durationMs = animateCssMode ? 1000 : 900;
}
if (!Number.isFinite(delayMs) || delayMs < 0) {
delayMs = 0;
}
if (!Number.isFinite(iterations) || iterations < 1) {
iterations = 1;
}
if (normalizedMode === 'continuous') {
return delayMs + (durationMs * iterations);
}
return delayMs + durationMs;
}
function normalizeAnimationStep(value, fallbackPreset) {
if (utils.normalizeAnimationStep) {
return utils.normalizeAnimationStep(value, fallbackPreset);
}
if (value && typeof value === 'object' && !Array.isArray(value)) {
var normalized = {};
Object.keys(value).forEach(function (key) {
normalized[key] = value[key];
});
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
normalized.fill_mode = String(value.fill_mode || '').trim();
return normalized;
}
return {
preset: normalizePreviewAnimation(typeof value === 'string' ? value : fallbackPreset || 'none'),
duration_ms: null,
delay_ms: null,
iterations: null,
fill_mode: ''
};
}
function normalizeAnimationConfig(value) {
if (utils.normalizeAnimationConfig) {
return utils.normalizeAnimationConfig(value);
}
var raw = value;
if (typeof raw === 'string') {
var text = String(raw || '').trim();
if (!text) {
raw = null;
} else {
try {
raw = JSON.parse(text);
} catch (_error) {
raw = null;
}
}
}
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
raw = {};
}
return {
intro: normalizeAnimationStep(raw.intro, 'none'),
outro: normalizeAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
loop: normalizeAnimationStep(raw.loop, 'none')
};
}
function getPreviewRegionAnimationStep(region, mode) {
if (!region) {
return normalizeAnimationStep({}, 'none');
}
var animationConfig = normalizeAnimationConfig(region.animation_json !== undefined ? region.animation_json : region);
if (mode === 'intro') {
return animationConfig.intro;
}
if (mode === 'out') {
return animationConfig.outro;
}
return animationConfig.loop;
}
function getAnimationConfig(card) {
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var animationConfig = normalizeAnimationConfig(hiddenAnimationInput ? hiddenAnimationInput.value || '{}' : '{}');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
if (introPresetInput && introPresetInput.dataset.animationPresetCustom !== '1' && introPresetInput.selectedIndex >= 0) {
animationConfig.intro.preset = normalizePreviewAnimation(introPresetInput.value);
}
if (outroPresetInput && outroPresetInput.dataset.animationPresetCustom !== '1' && outroPresetInput.selectedIndex >= 0) {
animationConfig.outro.preset = normalizePreviewAnimation(outroPresetInput.value);
}
if (loopPresetInput && loopPresetInput.dataset.animationPresetCustom !== '1' && loopPresetInput.selectedIndex >= 0) {
animationConfig.loop.preset = normalizePreviewAnimation(loopPresetInput.value);
}
return animationConfig;
}
function syncAnimationPresetCustomState(selectInput, resetButton, preset) {
if (!selectInput) {
return;
}
var normalizedPreset = normalizePreviewAnimation(preset);
var hasMatchingOption = Array.prototype.some.call(selectInput.options || [], function (option) {
return option && option.value === normalizedPreset;
});
if (hasMatchingOption) {
delete selectInput.dataset.animationPresetCustom;
selectInput.hidden = false;
if (resetButton) {
resetButton.hidden = true;
}
} else {
selectInput.dataset.animationPresetCustom = '1';
selectInput.hidden = true;
if (resetButton) {
resetButton.hidden = false;
resetButton.textContent = 'Reset to basic';
}
}
}
function writeAnimationConfig(card, animationConfig) {
var normalized = normalizeAnimationConfig(animationConfig);
var hiddenAnimationInput = card.querySelector('[name="region_animation_json[]"]');
var introPresetInput = card.querySelector('[data-animation-preset="intro"]');
var outroPresetInput = card.querySelector('[data-animation-preset="outro"]');
var loopPresetInput = card.querySelector('[data-animation-preset="loop"]');
var introResetButton = card.querySelector('[data-animation-reset-button="intro"]');
var outroResetButton = card.querySelector('[data-animation-reset-button="outro"]');
var loopResetButton = card.querySelector('[data-animation-reset-button="loop"]');
if (hiddenAnimationInput) {
hiddenAnimationInput.value = JSON.stringify(normalized, null, 2);
}
if (introPresetInput) {
introPresetInput.value = normalized.intro.preset;
syncAnimationPresetCustomState(introPresetInput, introResetButton, normalized.intro.preset);
}
if (outroPresetInput) {
outroPresetInput.value = normalized.outro.preset;
syncAnimationPresetCustomState(outroPresetInput, outroResetButton, normalized.outro.preset);
}
if (loopPresetInput) {
loopPresetInput.value = normalized.loop.preset;
syncAnimationPresetCustomState(loopPresetInput, loopResetButton, normalized.loop.preset);
}
updatePreviewCardVisibility();
return normalized;
}
function setAnimationPreset(card, stepName, preset) {
var config = getAnimationConfig(card);
if (config[stepName]) {
config[stepName].preset = normalizePreviewAnimation(preset);
config[stepName].duration_ms = null;
config[stepName].delay_ms = null;
config[stepName].iterations = null;
config[stepName].easing = '';
config[stepName].fill_mode = '';
writeAnimationConfig(card, config);
if (activeAnimationCard === card) {
populateAnimationModal(card);
}
}
}
function getAnimationModalField(stepName, fieldName) {
if (!animationAdvancedModal) {
return null;
}
return animationAdvancedModal.querySelector('[data-animation-modal-step="' + stepName + '"][data-animation-modal-field="' + fieldName + '"]');
}
function populateAnimationModal(card) {
if (!animationAdvancedModal || !card) {
return;
}
var title = animationAdvancedModal.querySelector('[data-animation-modal-title]');
var label = card.querySelector('[data-region-title]');
var config = getAnimationConfig(card);
if (title) {
title.textContent = (label ? label.textContent : 'Region') + ' animation settings';
}
['intro', 'outro', 'loop'].forEach(function (stepName) {
var step = config[stepName] || normalizeAnimationStep({}, 'none');
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
if (presetInput) { presetInput.value = step.preset; }
if (durationInput) { durationInput.value = step.duration_ms === null || step.duration_ms === undefined || Number(step.duration_ms) <= 0 ? '1000' : String(step.duration_ms); }
if (delayInput) { delayInput.value = step.delay_ms === null || step.delay_ms === undefined ? '0' : String(step.delay_ms); }
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(step.iterations); }
}
});
}
function readNumberField(value) {
var raw = String(value || '').trim();
if (!raw) {
return null;
}
var next = Number(raw);
return Number.isFinite(next) ? Math.round(next) : null;
}
function readAnimationModalConfig() {
if (!animationAdvancedModal) {
return null;
}
var config = getAnimationConfig(activeAnimationCard) || { intro: {}, outro: {}, loop: {} };
['intro', 'outro', 'loop'].forEach(function (stepName) {
var presetInput = getAnimationModalField(stepName, 'preset');
var durationInput = getAnimationModalField(stepName, 'duration_ms');
var delayInput = getAnimationModalField(stepName, 'delay_ms');
var nextStep = Object.assign({}, config[stepName] || {});
nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
nextStep.duration_ms = readNumberField(durationInput ? durationInput.value : '') || 1000;
nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
if (stepName === 'loop') {
var iterationsInput = getAnimationModalField(stepName, 'iterations');
nextStep.iterations = readNumberField(iterationsInput ? iterationsInput.value : '') || 1;
}
config[stepName] = normalizeAnimationStep(nextStep, 'none');
});
return config;
}
function saveAnimationModal() {
if (!activeAnimationCard) {
return;
}
var config = readAnimationModalConfig();
if (!config) {
return;
}
writeAnimationConfig(activeAnimationCard, config);
renderOverlay();
}
function setPreviewControlState(active) {
updatePreviewControlState();
}
function updatePreviewCardVisibility() {
if (!previewCard) {
return;
}
previewCard.hidden = !hasAnyAnimationCards();
}
function setTemplateEditorLocked(locked) {
if (templateForm) {
templateForm.classList.toggle('template-designer-form--previewing', locked);
}
if (stage) {
stage.classList.toggle('is-previewing', locked);
}
if (previewCard) {
previewCard.classList.toggle('is-previewing', locked);
}
if (overlay) {
overlay.style.pointerEvents = locked ? 'none' : '';
}
var controls = templateForm ? templateForm.querySelectorAll('input, select, textarea, button') : [];
Array.prototype.forEach.call(controls, function (control) {
if (!control) {
return;
}
if (control.matches && control.matches('[data-preview-control]')) {
return;
}
if (locked) {
if (!control.hasAttribute('data-preview-disabled')) {
control.setAttribute('data-preview-disabled', control.disabled ? '1' : '0');
}
control.disabled = true;
} else if (control.hasAttribute('data-preview-disabled')) {
control.disabled = control.getAttribute('data-preview-disabled') === '1';
control.removeAttribute('data-preview-disabled');
}
});
setPreviewControlState(locked);
}
function stopTemplatePreview(skipRender) {
if (previewState.stopTimer) {
window.clearTimeout(previewState.stopTimer);
previewState.stopTimer = 0;
}
previewState.token += 1;
previewState.active = false;
previewState.mode = '';
clearPreviewAnimations();
setTemplateEditorLocked(false);
if (!skipRender) {
renderOverlay();
}
}
function schedulePreviewStop(delayMs, token) {
if (previewState.stopTimer) {
window.clearTimeout(previewState.stopTimer);
}
previewState.stopTimer = window.setTimeout(function () {
if (token !== previewState.token) {
return;
}
stopTemplatePreview(false);
}, Math.max(0, Number(delayMs || 0)));
}
function animatePreviewRects(mode, regions) {
var rects = getPreviewRects();
var token = previewState.token;
var stagger = 90;
var duration = 900;
var overlayRect = getOverlayRect();
var canvasMovement = Math.max(12, Math.round(overlayRect.height * 0.02));
var longestTimingMs = 0;
previewState.animations = [];
rects.forEach(function (rect, index) {
var region = regions && regions[index] ? regions[index] : null;
var step = getPreviewRegionAnimationStep(region, mode);
var preset = normalizePreviewAnimation(step && step.preset);
var delay = index * stagger;
var animation = null;
var animateCssMode = isAnimateCssPreset(preset);
var timingMs = delay + getPreviewAnimationStepTimingMs(step, mode, animateCssMode);
rect.style.willChange = 'transform, opacity, filter';
if (timingMs > longestTimingMs) {
longestTimingMs = timingMs;
}
if (preset === 'none') {
return;
}
if (animateCssMode) {
applyAnimateCssPreview(rect, preset, step, mode);
return;
}
if (mode === 'intro') {
animation = rect.animate([
{ opacity: 0, transform: 'translate3d(0, ' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' },
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' }
], {
duration: duration,
delay: delay,
easing: 'cubic-bezier(0.22, 1, 0.36, 1)',
fill: 'both'
});
} else if (mode === 'out') {
animation = rect.animate([
{ opacity: 1, transform: 'translate3d(0, 0, 0) scale(1)', filter: 'blur(0)' },
{ opacity: 0, transform: 'translate3d(0, -' + canvasMovement + 'px, 0) scale(0.96)', filter: 'blur(1px)' }
], {
duration: duration,
delay: delay,
easing: 'cubic-bezier(0.4, 0, 1, 1)',
fill: 'both'
});
}
if (animation) {
previewState.animations.push(animation);
}
});
schedulePreviewStop(longestTimingMs + 140, token);
}
function startTemplatePreview(mode) {
var previewMode = String(mode || '').trim().toLowerCase();
if (!previewMode) {
return;
}
var previewRegions = getCards().map(readCard);
stopTemplatePreview(true);
previewState.active = true;
previewState.mode = previewMode;
previewState.token += 1;
setTemplateEditorLocked(true);
if (!getPreviewRects().length) {
schedulePreviewStop(120, previewState.token);
return;
}
animatePreviewRects(previewMode, previewRegions);
}
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
@@ -240,6 +835,7 @@
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); }
if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
@@ -401,6 +997,7 @@
if (regionLockRatioInput) {
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
}
writeAnimationConfig(card, region.animation_json);
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
@@ -452,6 +1049,9 @@
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var widthInput = card.querySelector('[name="region_width[]"]');
var heightInput = card.querySelector('[name="region_height[]"]');
var animationPresetInputs = card.querySelectorAll('[data-animation-preset]');
var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
@@ -459,6 +1059,28 @@
renderRegionSidebar();
renderOverlay();
});
Array.prototype.forEach.call(animationPresetInputs, function (input) {
input.addEventListener('change', function () {
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
requestOverlayRender();
});
});
Array.prototype.forEach.call(animationResetButtons, function (button) {
button.addEventListener('click', function (event) {
event.preventDefault();
setAnimationPreset(card, button.getAttribute('data-animation-reset-button'), 'none');
requestOverlayRender();
});
});
if (animationAdvancedButton && animationAdvancedModal) {
animationAdvancedButton.addEventListener('click', function () {
activeAnimationCard = card;
populateAnimationModal(card);
if (window.pulseModal) {
window.pulseModal.show(animationAdvancedModal);
}
});
}
if (lockRatioInput) {
lockRatioInput.addEventListener('input', function () {
updateRegionLockBadge(card);
@@ -536,28 +1158,49 @@
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
updatePreviewCardVisibility();
}
function renderOverlay() {
if (previewState.active) {
return;
}
var cards = getCards();
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
var regions = cards.map(readCard);
var canvasSize = getCanvasSize();
var overlayRect = getOverlayRect();
var overlayScale = overlayRect.width / Math.max(1, canvasSize.width);
overlay.style.setProperty('--designer-overlay-scale', String(overlayScale));
regionsJsonInput.value = JSON.stringify(regions);
overlay.innerHTML = regions.map(function (region, index) {
var box = canvasRectToPixels(region);
var renderedBox = {
left: Math.round(box.left),
top: Math.round(box.top),
width: Math.round(box.width),
height: Math.round(box.height)
};
var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="--designer-overlay-scale:1;left:' + renderedBox.left + 'px;top:' + renderedBox.top + 'px;width:' + renderedBox.width + 'px;height:' + renderedBox.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
if (draft) {
var rect = getOverlayRect();
var size = getCanvasSize();
var rect = overlayRect;
var size = canvasSize;
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + Math.round((draftBox.x / size.width) * rect.width) + 'px;top:' + Math.round((draftBox.y / size.height) * rect.height) + 'px;width:' + Math.round((draftBox.width / size.width) * rect.width) + 'px;height:' + Math.round((draftBox.height / size.height) * rect.height) + 'px;"></div>';
}
updatePreviewControlState();
}
function requestOverlayRender() {
if (previewState.active) {
return;
}
if (overlayRenderFrame) {
return;
}
@@ -575,6 +1218,10 @@
}
function setSelected(index) {
if (previewState.active) {
return;
}
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
@@ -588,6 +1235,10 @@
}
function addRegion(region) {
if (previewState.active) {
return;
}
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
@@ -613,6 +1264,7 @@
region_key: name,
label: name,
region_type: type,
animation_json: normalizeAnimationConfig('{}'),
x: 80,
y: 80,
width: size.width,
@@ -639,6 +1291,10 @@
}
function startDraw(event) {
if (previewState.active) {
return;
}
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
@@ -664,6 +1320,10 @@
}
function startMove(index, event) {
if (previewState.active) {
return;
}
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
@@ -687,6 +1347,10 @@
}
function resizeFromHandle(index, dir, event) {
if (previewState.active) {
return;
}
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
@@ -824,6 +1488,31 @@
backgroundEmpty.style.display = 'block';
});
}
if (animationAdvancedModal) {
animationAdvancedModal.addEventListener('hidden.bs.modal', function () {
activeAnimationCard = null;
});
var animationModalApplyButton = animationAdvancedModal.querySelector('[data-animation-modal-apply]');
var animationModalResetButton = animationAdvancedModal.querySelector('[data-animation-modal-reset]');
if (animationModalApplyButton) {
animationModalApplyButton.addEventListener('click', function () {
saveAnimationModal();
if (window.pulseModal) {
window.pulseModal.hide(animationAdvancedModal);
}
});
}
if (animationModalResetButton) {
animationModalResetButton.addEventListener('click', function () {
if (!activeAnimationCard) {
return;
}
writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));
populateAnimationModal(activeAnimationCard);
requestOverlayRender();
});
}
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
@@ -853,6 +1542,11 @@
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
if (previewState.active) {
event.preventDefault();
return;
}
if (!validateRegionNames()) {
event.preventDefault();
return;
@@ -868,6 +1562,10 @@
});
templateForm.addEventListener('submit', function () {
if (previewState.active) {
return;
}
if (!validateRegionNames()) {
return;
}
@@ -882,6 +1580,36 @@
});
}
if (previewIntroButton) {
previewIntroButton.addEventListener('click', function () {
if (previewState.active && previewState.mode === 'intro') {
stopTemplatePreview(false);
return;
}
startTemplatePreview('intro');
});
}
if (previewOutButton) {
previewOutButton.addEventListener('click', function () {
if (previewState.active && previewState.mode === 'out') {
stopTemplatePreview(false);
return;
}
startTemplatePreview('out');
});
}
if (previewContinuousButton) {
previewContinuousButton.addEventListener('click', function () {
if (previewState.active && previewState.mode === 'continuous') {
stopTemplatePreview(false);
return;
}
startTemplatePreview('continuous');
});
}
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
File diff suppressed because one or more lines are too long
+4 -4
View File
@@ -501,8 +501,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[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 syncPlaylistUploadsOnChange({
@@ -563,8 +563,8 @@ module.exports = function registerContentRoutes(app, deps) {
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
await pool.query(
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[template.id, region.region_key, region.region_type, region.label, region.lock_ratio, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[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 syncPlaylistUploadsOnChange({
@@ -1,6 +1,7 @@
// Shared template form view-model builder.
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
const animationPresets = require('../../../public/js/templates/animation-presets');
function buildDefaultTemplate() {
return {
@@ -61,7 +62,8 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
cancelUrl: '/templates',
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
canvasSizes: canvasSizes || [],
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
animationPresets: animationPresets,
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
};
}
+1
View File
@@ -11,6 +11,7 @@
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
{{#if stylesheets.length}}
{{#each stylesheets}}
<link rel="stylesheet" href="{{assetHref this}}" />
@@ -0,0 +1,100 @@
{{#> modal-shell modalId="animation-advanced-modal" modalLabelId="animation-advanced-modal-label" modalDialogClass="modal-dialog-centered modal-xl modal-dialog-scrollable" modalBackdropStatic=true modalKeyboardDisabled=true}}
<div class="modal-header">
<div>
<h2 class="modal-title fs-5" id="animation-advanced-modal-label" data-animation-modal-title>Advanced animation settings</h2>
<p class="text-body-secondary mb-0">Fine-tune the selected region animation JSON without editing raw data.</p>
</div>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-4">
<div class="alert alert-secondary mb-0">Changes here are saved into the region animation JSON and stay in sync with the preset dropdowns.</div>
<div class="row g-3">
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Intro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="intro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="duration_ms" placeholder="1000" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="intro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Outro</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="outro" data-animation-modal-field="preset">
{{#each animationPresets.advanced.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="duration_ms" placeholder="1000" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="outro" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
</div>
</div>
</div>
<div class="col-12 col-lg-4">
<div class="card border-secondary-subtle">
<div class="card-header bg-transparent">
<strong class="text-capitalize">Attention seekers</strong>
</div>
<div class="card-body d-grid gap-3 align-content-start">
<label class="d-grid gap-1">
Preset
<select class="form-select" data-animation-modal-step="loop" data-animation-modal-field="preset">
{{#each animationPresets.advanced.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<label class="d-grid gap-1">
Duration ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="duration_ms" placeholder="1000" />
</label>
<label class="d-grid gap-1">
Delay ms
<input class="form-control" type="number" min="0" step="1" data-animation-modal-step="loop" data-animation-modal-field="delay_ms" placeholder="0" />
</label>
<label class="d-grid gap-1">
Repeat
<input class="form-control" type="number" min="1" step="1" data-animation-modal-step="loop" data-animation-modal-field="iterations" placeholder="1" />
</label>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer justify-content-between">
<button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button>
<div class="d-flex gap-2">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-primary" data-animation-modal-apply>Apply changes</button>
</div>
</div>
{{/modal-shell}}
+57 -3
View File
@@ -31,10 +31,17 @@
</div>
<div class="template-designer-sidebar">
<div class="card card-outline card-primary admin-form-card">
<div class="card-body d-grid">
<div class="card-body d-grid gap-3 pb-0">
<div class="btn-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
<div class="d-grid gap-2 template-preview-card" id="template-preview-card" hidden>
<div class="btn-group flex-wrap gap-0" role="group" aria-label="Template animation preview controls">
<button type="button" class="btn btn-outline-secondary" id="template-preview-intro" data-preview-control data-preview-action="intro" aria-pressed="false">Intro</button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-out" data-preview-control data-preview-action="out" aria-pressed="false">Outro</button>
<button type="button" class="btn btn-outline-secondary" id="template-preview-continuous" data-preview-control data-preview-action="continuous" aria-pressed="false">Attention</button>
</div>
</div>
</div>
</div>
<div class="card card-outline card-secondary admin-form-card region-info-card mb-3">
@@ -131,7 +138,7 @@
<template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
<div class="card-header template-field-head d-flex align-items-center flex-nowrap gap-2"><strong class="flex-grow-1 text-truncate" data-region-title>Region</strong><span class="chip ms-auto" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3 pb-0">
<div class="region-field-grid region-field-grid--identity">
<label>Region name<input class="form-control" name="region_name[]" value="" placeholder="region_1" required /></label>
@@ -148,10 +155,57 @@
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
<label>Remove<button type="button" class="btn btn-outline-danger w-100" data-region-remove-button{{#if isEdit}} disabled{{/if}}>Remove</button></label>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Intro
<select class="form-select" data-animation-preset="intro">
{{#each animationPresets.basic.intro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="intro" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Outro
<select class="form-select" data-animation-preset="outro">
{{#each animationPresets.basic.outro}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="outro" hidden>Reset to basic</button>
</div>
</div>
<div class="region-field-grid region-field-grid--animation">
<div class="d-grid gap-1">
<label>
Attention seekers
<select class="form-select" data-animation-preset="loop">
{{#each animationPresets.basic.attentionSeekers}}
<option value="{{value}}">{{label}}</option>
{{/each}}
</select>
</label>
<button type="button" class="btn btn-outline-secondary w-100" data-animation-reset-button="loop" hidden>Reset to basic</button>
</div>
<div class="d-grid gap-1">
<label>
Advanced
<button type="button" class="btn btn-outline-secondary w-100" data-animation-advanced-button>Advanced</button>
</label>
</div>
</div>
<textarea class="visually-hidden" name="region_animation_json[]" data-animation-json-input rows="1"></textarea>
</div>
</div>
</div>
</template>
{{> signage/templates/animation-advanced-modal}}
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
+91
View File
@@ -0,0 +1,91 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const crypto = require('node:crypto');
const {
collectLiveConnections,
isClientNameAvailable,
normalizeClientName,
normalizeDeviceId,
withClientNameReservation
} = require('../src/data/client-name-check');
test('normalize helpers trim names and sanitize device ids', () => {
assert.equal(normalizeClientName(' Screen A '), 'Screen A');
assert.equal(normalizeClientName(''), '');
assert.equal(normalizeDeviceId(' device-01 /abc!? '), 'device-01abc');
assert.equal(normalizeDeviceId('x'.repeat(200)).length, 128);
});
test('collectLiveConnections returns an array safely', () => {
assert.deepEqual(collectLiveConnections(null), []);
assert.deepEqual(collectLiveConnections([{ clientName: 'A' }]), [{ clientName: 'A' }]);
});
test('isClientNameAvailable rejects matching db rows and live connections', async () => {
const pool = {
async query(sql, params) {
assert.match(sql, /FROM d_onboarding_devices/);
assert.deepEqual(params, ['Screen A', 'device-01']);
return [[{ device_id: 'device-99' }]];
}
};
assert.equal(await isClientNameAvailable(pool, ' Screen A ', 'device-01', []), false);
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-99' }]), false);
assert.equal(await isClientNameAvailable(null, 'Screen A', 'device-01', [{ clientName: 'screen a', clientId: 'device-01' }]), true);
assert.equal(await isClientNameAvailable(null, ' ', 'device-01', []), false);
});
test('withClientNameReservation acquires and releases locks around the handler', async () => {
const calls = [];
const lockName = `ps_client_name_${crypto.createHash('sha1').update('screen a').digest('hex')}`;
const connection = {
async query(sql, params) {
calls.push({ sql, params });
if (sql.includes('GET_LOCK')) {
return [[{ lock_result: 1 }]];
}
return [[{ released: 1 }]];
},
release() {
calls.push({ sql: 'RELEASE_CONNECTION' });
}
};
const pool = {
async getConnection() {
return connection;
}
};
const result = await withClientNameReservation(pool, ' Screen A ', async () => 'ok');
assert.equal(result, 'ok');
assert.deepEqual(calls, [
{ sql: 'SELECT GET_LOCK(?, 5) AS lock_result', params: [lockName] },
{ sql: 'SELECT RELEASE_LOCK(?)', params: [lockName] },
{ sql: 'RELEASE_CONNECTION' }
]);
});
test('withClientNameReservation rejects busy names', async () => {
const connection = {
async query(sql) {
if (sql.includes('GET_LOCK')) {
return [[{ lock_result: 0 }]];
}
throw new Error('unexpected query');
},
release() {}
};
const pool = {
async getConnection() {
return connection;
}
};
await assert.rejects(
() => withClientNameReservation(pool, 'Screen A', async () => 'ok'),
(error) => error && error.statusCode === 409 && error.message === 'Client name is busy. Please try again.'
);
});
+105
View File
@@ -0,0 +1,105 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildSearchFilter,
buildSortOrderClause,
fetchPagedRows,
findTopLevelOrderByIndex,
normalizePageNumber,
normalizeSortDirection,
parseJsonSafe,
readFormArray
} = require('../src/data/utils');
test('parseJsonSafe returns null for invalid JSON and preserves objects', () => {
assert.equal(parseJsonSafe(''), null);
assert.equal(parseJsonSafe('not-json'), null);
assert.deepEqual(parseJsonSafe('{"value":true}'), { value: true });
const value = { nested: ['a'] };
assert.equal(parseJsonSafe(value), value);
});
test('readFormArray always returns an array shape', () => {
assert.deepEqual(readFormArray({}, 'roles'), []);
assert.deepEqual(readFormArray({ roles: 'admin' }, 'roles'), ['admin']);
assert.deepEqual(readFormArray({ roles: ['admin', 'editor'] }, 'roles'), ['admin', 'editor']);
});
test('normalize helpers clamp pagination and sort direction', () => {
assert.equal(normalizePageNumber('0'), 1);
assert.equal(normalizePageNumber('3.9'), 3);
assert.equal(normalizePageNumber('abc'), 1);
assert.equal(normalizeSortDirection('DESC'), 'desc');
assert.equal(normalizeSortDirection('anything else'), 'asc');
});
test('buildSortOrderClause applies multiple columns and ignores unknown keys', () => {
assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'name', 'desc'), {
clause: ' ORDER BY title DESC',
sortKey: 'name',
sortDirection: 'desc'
});
assert.deepEqual(buildSortOrderClause({ name: ['title', 'id'] }, 'name', 'asc'), {
clause: ' ORDER BY title ASC, id ASC',
sortKey: 'name',
sortDirection: 'asc'
});
assert.deepEqual(buildSortOrderClause({ name: 'title' }, 'missing', 'desc'), {
clause: '',
sortKey: 'missing',
sortDirection: 'desc'
});
});
test('buildSearchFilter generates escaped LIKE clauses', () => {
assert.deepEqual(buildSearchFilter(['name', 'description'], '100%_ready'), {
clause: " WHERE (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\' OR LOWER(COALESCE(CAST(description AS CHAR), '')) LIKE ? ESCAPE '\\\\')",
params: ['%100\\%\\_ready%', '%100\\%\\_ready%']
});
assert.deepEqual(buildSearchFilter([], 'anything'), { clause: '', params: [] });
});
test('findTopLevelOrderByIndex skips nested subqueries', () => {
const sql = 'SELECT * FROM (SELECT * FROM items ORDER BY created_at DESC) AS nested ORDER BY id ASC';
assert.equal(findTopLevelOrderByIndex(sql), sql.lastIndexOf('ORDER BY id ASC'));
});
test('fetchPagedRows combines search, ordering, and pagination parameters', async () => {
const queries = [];
const pool = {
async query(sql, params) {
queries.push({ sql, params });
if (sql.startsWith('SELECT COUNT(*)')) {
return [[{ count: 3 }]];
}
return [[{ id: 1 }]];
}
};
const result = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name FROM items WHERE active = 1 ORDER BY name ASC',
countSql: 'SELECT COUNT(*) AS count FROM items WHERE active = 1',
params: ['active'],
searchColumns: ['name'],
searchTerm: 'alpha',
sortColumns: { name: 'name' },
sortKey: 'name',
sortDirection: 'desc',
pageSize: 2,
page: 2
});
assert.deepEqual(result, {
rows: [{ id: 1 }],
totalItems: 3,
totalPages: 2,
currentPage: 2,
pageSize: 2
});
assert.equal(queries.length, 2);
assert.equal(queries[0].sql, "SELECT COUNT(*) AS count FROM (SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\')) AS filtered_rows");
assert.deepEqual(queries[0].params, ['active', '%alpha%']);
assert.equal(queries[1].sql, "SELECT id, name FROM items WHERE active = 1 AND (LOWER(COALESCE(CAST(name AS CHAR), '')) LIKE ? ESCAPE '\\\\') ORDER BY name DESC LIMIT ? OFFSET ?");
assert.deepEqual(queries[1].params, ['active', '%alpha%', 2, 2]);
});
+148
View File
@@ -0,0 +1,148 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const { createPlayerPlaylistService } = require('../src/player/playlist');
function createFsStub() {
const original = {
readFile: fs.promises.readFile,
writeFile: fs.promises.writeFile,
mkdir: fs.promises.mkdir
};
const calls = [];
fs.promises.readFile = async function (filePath) {
calls.push({ method: 'readFile', filePath });
if (filePath.endsWith('fallback.json')) {
return JSON.stringify({ fromSnapshot: true, screen: { slug: 'test2' } });
}
const error = new Error('missing');
error.code = 'ENOENT';
throw error;
};
fs.promises.writeFile = async function (filePath, content) {
calls.push({ method: 'writeFile', filePath, content });
};
fs.promises.mkdir = async function (dirPath, options) {
calls.push({ method: 'mkdir', dirPath, options });
};
return {
calls,
restore() {
fs.promises.readFile = original.readFile;
fs.promises.writeFile = original.writeFile;
fs.promises.mkdir = original.mkdir;
}
};
}
test('buildScreenPlaylist assembles slides, templates, and derived values', async () => {
const pool = {
async query(sql, params) {
if (sql.includes('FROM d_screens')) {
assert.deepEqual(params, ['test2']);
return [[{ id: 7, name: 'Screen 7', slug: 'test2', playlist_id: 22, modified_at: '2026-08-03T00:00:00.000Z' }]];
}
if (sql.includes('FROM c_playlists')) {
assert.deepEqual(params, [22]);
return [[{ id: 22, name: 'Playlist 22', fade_between_slides: 1, skip_unavailable_rtmp: 0, modified_at: '2026-08-02T00:00:00.000Z' }]];
}
if (sql.includes('FROM c_playlist_slides ps')) {
return [[{
id: 101,
title: 'Intro',
template_id: 33,
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"textRegion":{"type":"text","value":"Hello"}}',
modified_at: '2026-08-03T00:00:01.000Z',
position: 1,
duration_seconds: 9,
use_video_duration: 1,
disable_audio: null,
template_name: 'Template 33',
canvas_size_name: 'HD',
canvas_size_width: 1920,
canvas_size_height: 1080,
canvas_width: 1920,
canvas_height: 1080
}]];
}
if (sql.includes('FROM c_playlist_slide_schedule_rules')) {
return [[
{ id: 500, playlist_slide_id: 101, position: 1, start_datetime: null, end_datetime: null, start_time: '08:00', end_time: '12:00', schedule_days_json: '[1,2,3]' },
{ id: 501, playlist_slide_id: 101, position: 2, start_datetime: null, end_datetime: null, start_time: '13:00', end_time: '17:00', schedule_days_json: '[4,5]' }
]];
}
if (sql.includes('FROM c_templates st')) {
assert.deepEqual(params, [[33]]);
return [[{ id: 33, name: 'Template 33', canvas_size_id: 4, canvas_size_width: 1920, canvas_size_height: 1080, background_image_path: '/media/bg.png', background_color: '#111111', modified_at: '2026-08-03T00:00:02.000Z' }]];
}
if (sql.includes('FROM c_template_regions')) {
assert.deepEqual(params, [[33]]);
return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
}
throw new Error(`unexpected query: ${sql}`);
}
};
const common = {
parseJsonSafe(value) {
return JSON.parse(value);
},
fetchRssFeedsData: async () => ({ rssFeeds: [{ id: 1, title: 'Feed 1' }] }),
fetchRssFeedItemsByFeedId: async () => ([{ title: 'Item 1', link: 'https://example.com' }]),
normalizeRssFeedItem(item) {
return Object.assign({}, item, { normalized: true });
},
fetchApiSourcesData: async () => ({ apiSources: [{ id: 2, name: 'Source 2', last_response_json: '{"ok":true}' }] }),
fetchTimetablesData: async () => ({ timetableGroups: [{ id: 3, name: 'Group 3' }] })
};
const fsStub = createFsStub();
try {
const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
const payload = await service.buildScreenPlaylist('test2');
assert.equal(payload.screen.slug, 'test2');
assert.equal(payload.playlist.name, 'Playlist 22');
assert.equal(payload.slides[0].duration_seconds, 12.346);
assert.equal(payload.slides[0].disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
assert.equal(payload.slides[0].scheduleRules.length, 2);
assert.equal(payload.slides[0].template.regions[0].label, 'Text Region');
assert.equal(payload.rssFeeds[0].items[0].normalized, true);
assert.deepEqual(payload.apiSources[0].responseJson, { ok: true });
assert.deepEqual(payload.timetableGroups, [{ id: 3, name: 'Group 3' }]);
assert.match(payload.revision, /^[a-f0-9]{40}$/);
assert.ok(fsStub.calls.some((call) => call.method === 'writeFile'));
} finally {
fsStub.restore();
}
});
test('buildScreenPlaylist falls back to a snapshot when queries fail', async () => {
const pool = {
async query() {
throw new Error('database unavailable');
}
};
const common = {
parseJsonSafe() {
throw new Error('not expected');
}
};
const fsStub = createFsStub();
try {
const service = createPlayerPlaylistService({ pool, common, snapshotDir: 'C:\\tmp\\snapshots' });
const payload = await service.buildScreenPlaylist('fallback');
assert.deepEqual(payload, { fromSnapshot: true, screen: { slug: 'test2' } });
assert.ok(fsStub.calls.some((call) => call.method === 'readFile'));
} finally {
fsStub.restore();
}
});
+81
View File
@@ -0,0 +1,81 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const {
mediaKind,
normalizeSlide,
renderEditorJsContent,
sanitizeRichText
} = require('../src/player/render-helpers');
test('mediaKind classifies player media by extension', () => {
assert.equal(mediaKind('poster.PNG'), 'image');
assert.equal(mediaKind('intro.mp4'), 'video');
assert.equal(mediaKind('manual.pdf'), 'pdf');
assert.equal(mediaKind('notes.txt'), 'file');
});
test('sanitizeRichText strips unsafe content but preserves allowed markup', () => {
const html = '<div class="wrap"><a href="https://example.com" target="_blank">Link</a><script>alert(1)</script><span style="color:red">Text</span><img src="x" onerror="alert(1)"></div>';
assert.equal(
sanitizeRichText(html),
'<div class="wrap"><a href="https://example.com" target="_blank" rel="noreferrer noopener">Link</a><span style="color:red">Text</span></div>'
);
});
test('normalizeSlide normalizes nested content without mutating the source', () => {
const slide = {
id: 12,
content: {
hero: {
value: '{"headline":"Hello"}',
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
type: 'text'
},
footer: 'plain text'
}
};
const normalized = normalizeSlide(slide);
assert.notEqual(normalized.content, slide.content);
assert.deepEqual(normalized.content.hero, {
value: { headline: 'Hello' },
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
type: 'text'
});
assert.deepEqual(normalized.content.footer, {
type: 'text',
value: 'plain text'
});
assert.deepEqual(slide.content.hero, {
value: '{"headline":"Hello"}',
font_family: ' Open Sans! ',
font_size: '42',
font_color: 'not-a-color',
type: 'text'
});
});
test('renderEditorJsContent sanitizes editor blocks and wraps legacy text', () => {
const editorJson = {
blocks: [
{ type: 'header', data: { level: 2, text: '<strong>Title</strong><script>bad()</script>' } },
{ type: 'paragraph', data: { text: '<a href="javascript:alert(1)">bad</a><em>ok</em>' } },
{ type: 'list', data: { style: 'ordered', items: ['One', { text: '<span>Two</span>' }] } }
]
};
assert.equal(
renderEditorJsContent(editorJson),
'<h2><strong>Title</strong></h2><p><a>bad</a><em>ok</em></p><ol style="list-style-type:decimal;padding-left:1.4em;"><li>One</li><li><span>Two</span></li></ol>'
);
assert.equal(renderEditorJsContent('plain text'), '<p>plain text</p>');
});
+396
View File
@@ -0,0 +1,396 @@
const test = require('node:test');
const assert = require('node:assert/strict');
require('../src/common');
const { registerPlayerRoutes } = require('../src/player/routes');
function createAppAndHandlers() {
const handlers = {};
const app = {
use() {},
get(path, ...routeHandlers) {
handlers[path] = function (req, res) {
return new Promise((resolve, reject) => {
const runHandler = (index) => {
const handler = routeHandlers[index];
if (!handler) {
resolve();
return;
}
let nextCalled = false;
const next = function () {
nextCalled = true;
return runHandler(index + 1);
};
let result;
try {
result = handler(req, res, next);
} catch (error) {
reject(error);
return;
}
Promise.resolve(result).then(function (value) {
if (!nextCalled) {
resolve(value);
}
}, reject);
};
runHandler(0);
});
};
},
post() {},
put() {},
delete() {}
};
return { app, handlers };
}
function createResponse() {
const headers = {};
return {
headers,
statusCode: 200,
body: null,
ended: false,
set(name, value) {
headers[name] = value;
return this;
},
status(code) {
this.statusCode = code;
return this;
},
json(value) {
this.body = value;
return this;
},
send(value) {
this.body = value;
return this;
},
end() {
this.ended = true;
return this;
}
};
}
function createPlayerRouteOptions(overrides) {
return Object.assign({
mediaDir: 'e:\\Projects Git\\pulse-signage\\media',
assetDir: 'e:\\Projects Git\\pulse-signage\\src\\player\\public',
playerRuntime: { broadcastAnnouncementRefresh() {}, snapshotConnections() { return []; } },
playerPlaylistService: { async buildScreenPlaylist() { return { screen: { slug: 'test2' } }; } },
rtmpStreamService: {
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
},
playerIdentifier: ''
}, overrides);
}
test('screen route falls back to offline rendering when playlist build fails', async () => {
const { app, handlers } = createAppAndHandlers();
const renderCalls = [];
const originalConsoleError = console.error;
console.error = () => {};
const pool = {
async query() {
return [[{ id: 1 }]];
},
async getConnection() {
return {
async beginTransaction() {},
async query() {},
async commit() {},
async rollback() {},
release() {}
};
}
};
const common = {
renderPlayerPage(slug, data) {
renderCalls.push({ slug, data });
return data ? 'online' : 'offline';
}
};
registerPlayerRoutes(app, {
app,
pool,
common,
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist() {
const error = new Error('db unavailable');
error.code = 'ECONNREFUSED';
throw error;
}
}
})
});
const handler = handlers['/screen/:slug'];
assert.equal(typeof handler, 'function');
const res = createResponse();
try {
await handler({ params: { slug: 'test2' } }, res);
await new Promise((resolve) => setImmediate(resolve));
assert.equal(res.headers['X-Player-Offline'], '1');
assert.equal(res.body, 'offline');
assert.deepEqual(renderCalls, [
{ slug: 'test2', data: null }
]);
} finally {
console.error = originalConsoleError;
}
});
test('screen route renders the shell when playlist data is missing', async () => {
const { app, handlers } = createAppAndHandlers();
const renderCalls = [];
const pool = {
async query() {
return [[{ id: 1 }]];
},
async getConnection() {
return {
async beginTransaction() {},
async query() {},
async commit() {},
async rollback() {},
release() {}
};
}
};
const common = {
renderPlayerPage(slug, data) {
renderCalls.push({ slug, data });
return '<html><body><div id="app"><div class="empty">Loading screen...</div></div></body></html>';
}
};
registerPlayerRoutes(app, {
app,
pool,
common,
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist() {
return {
screen: { id: 7, slug: 'test2' },
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'abc123'
};
}
}
})
});
const handler = handlers['/screen/:slug'];
const res = createResponse();
await handler({ params: { slug: 'test2' } }, res);
assert.equal(res.statusCode, 200);
assert.match(String(res.body), /Loading screen/);
assert.deepEqual(renderCalls, [
{
slug: 'test2',
data: {
screen: { id: 7, slug: 'test2' },
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'abc123'
}
}
]);
});
test('playlist api route returns 404, etag, and 304 responses', async () => {
const { app, handlers } = createAppAndHandlers();
const playlistData = {
screen: { id: 7, slug: 'test2' },
playlist: { id: 22 },
slides: [],
rssFeeds: [],
apiSources: [],
timetableGroups: [],
revision: 'rev-123'
};
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: { renderPlayerPage() { return ''; } },
...createPlayerRouteOptions({
playerPlaylistService: {
async buildScreenPlaylist(slug) {
if (slug === 'missing') {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [], revision: 'none' };
}
return playlistData;
}
}
})
});
const handler = handlers['/api/screens/:slug/playlist'];
assert.equal(typeof handler, 'function');
const missingRes = createResponse();
await handler({ params: { slug: 'missing' }, headers: {} }, missingRes);
assert.equal(missingRes.statusCode, 404);
assert.deepEqual(missingRes.body, { error: 'Screen not found' });
const okRes = createResponse();
await handler({ params: { slug: 'test2' }, headers: {} }, okRes);
assert.equal(okRes.statusCode, 200);
assert.equal(okRes.headers.ETag, '"rev-123"');
assert.deepEqual(okRes.body, playlistData);
const notModifiedRes = createResponse();
await handler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"rev-123"' } }, notModifiedRes);
assert.equal(notModifiedRes.statusCode, 304);
assert.equal(notModifiedRes.ended, true);
});
test('announcement route returns 503 for transient failures and 304 on matching etag', async () => {
const { app, handlers } = createAppAndHandlers();
const announcement = {
id: 9,
modified_at: '2026-08-03T00:00:00.000Z',
expires_at: '2026-08-04T00:00:00.000Z',
enabled: true
};
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: {
async fetchActiveAnnouncement() {
const error = new Error('db unavailable');
error.code = 'ECONNREFUSED';
throw error;
},
renderPlayerPage() { return ''; }
},
...createPlayerRouteOptions({
rtmpStreamService: {
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
}
})
});
const handler = handlers['/api/screens/:slug/announcement'];
const unavailableRes = createResponse();
await handler({ params: { slug: 'test2' }, headers: {} }, unavailableRes);
assert.equal(unavailableRes.statusCode, 503);
assert.deepEqual(unavailableRes.body, { error: 'Announcement state unavailable.' });
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: {
async fetchActiveAnnouncement() {
return announcement;
},
renderPlayerPage() { return ''; }
},
...createPlayerRouteOptions({
rtmpStreamService: {
async getSessionStatus() { return { ready: false, live: false, session: {} }; },
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
}
})
});
const okHandler = handlers['/api/screens/:slug/announcement'];
const okRes = createResponse();
await okHandler({ params: { slug: 'test2' }, headers: {} }, okRes);
assert.equal(okRes.statusCode, 200);
assert.equal(okRes.headers.ETag, '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"');
assert.deepEqual(okRes.body, { announcement, revision: '9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1' });
const notModifiedRes = createResponse();
await okHandler({ params: { slug: 'test2' }, headers: { 'if-none-match': '"9:2026-08-03T00:00:00.000Z:2026-08-04T00:00:00.000Z:1"' } }, notModifiedRes);
assert.equal(notModifiedRes.statusCode, 304);
assert.equal(notModifiedRes.ended, true);
});
test('rtmp session route returns not-ready and ready payloads', async () => {
const { app, handlers } = createAppAndHandlers();
const sessionCalls = [];
registerPlayerRoutes(app, {
app,
pool: { async query() { return [[{ id: 1 }]]; } },
common: { renderPlayerPage() { return ''; } },
...createPlayerRouteOptions({
rtmpStreamService: {
async getSessionStatus(source, useMutedOutput) {
sessionCalls.push({ source, useMutedOutput });
if (source === 'ready') {
return {
ready: true,
live: true,
session: { key: 'abc', playlistUrl: 'http://example.com/live.m3u8', disableAudio: useMutedOutput }
};
}
return {
ready: false,
live: false,
timedOut: true,
stderr: 'starting',
session: {}
};
},
async getManifestFilePath() { return null; },
async getSegmentFilePath() { return null; }
}
})
});
const handler = handlers['/api/rtmp/session'];
const unavailableRes = createResponse();
await handler({ headers: {}, query: { source: 'cold', disableAudio: 'true' } }, unavailableRes);
assert.equal(unavailableRes.statusCode, 503);
assert.deepEqual(unavailableRes.body, { ready: false, live: false, timedOut: true, stderr: 'starting' });
const readyRes = createResponse();
await handler({ headers: {}, query: { source: 'ready', disableAudio: 'yes' } }, readyRes);
assert.equal(readyRes.statusCode, 200);
assert.deepEqual(readyRes.body, {
key: 'abc',
playlistUrl: 'http://example.com/live.m3u8',
disableAudio: true,
ready: true,
live: true
});
assert.deepEqual(sessionCalls, [
{ source: 'cold', useMutedOutput: true },
{ source: 'ready', useMutedOutput: true }
]);
});
+192
View File
@@ -0,0 +1,192 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const http = require('http');
const WebSocket = require('ws');
require('../src/common');
const { createPageAuthToken } = require('../src/request-auth');
const { createPlayerRuntime } = require('../src/player/runtime');
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
test.after(() => {
if (originalSecret === undefined) {
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
} else {
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
function waitFor(predicate, timeoutMs = 1000) {
const start = Date.now();
return new Promise((resolve, reject) => {
const tick = () => {
const value = predicate();
if (value) {
return resolve(value);
}
if (Date.now() - start > timeoutMs) {
return reject(new Error('Timed out waiting for runtime state.'));
}
setTimeout(tick, 25);
};
tick();
});
}
test('player runtime snapshots websocket state and checks live names', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null });
const server = http.createServer();
runtime.installWebsocket(server);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
try {
await new Promise((resolve, reject) => {
client.once('open', resolve);
client.once('error', reject);
});
client.send(JSON.stringify({
type: 'state',
clientId: 'client-1',
clientName: ' Lobby Player ',
deviceId: 'device 123!?',
userAgent: 'Mozilla/5.0 (unit test)',
viewport: { width: 1920, height: 1080 },
page: 'http://localhost:8081/screen/test2',
paused: true,
blackout: false,
currentSlide: { id: 9, title: 'Intro', kind: 'slide', playlistSignature: 'sig' }
}));
await waitFor(() => {
const snapshot = runtime.snapshotConnections('test2')[0];
return snapshot && snapshot.clientName === 'Lobby Player' ? snapshot : null;
});
const snapshot = runtime.snapshotConnections('test2')[0];
assert.equal(snapshot.clientName, 'Lobby Player');
assert.equal(snapshot.deviceId, 'device123');
assert.match(snapshot.label, /Lobby Player/);
assert.equal(snapshot.paused, true);
assert.equal(snapshot.currentSlideId, 9);
assert.equal(snapshot.currentSlideTitle, 'Intro');
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'other-device'), false);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Lobby Player', 'device123'), true);
assert.equal(await runtime.isClientNameAvailableOnScreen(null, 'Other Name', 'device123'), true);
} finally {
client.close();
await new Promise((resolve) => server.close(resolve));
}
});
test('player runtime accepts websocket auth from cookies', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null });
const server = http.createServer();
runtime.installWebsocket(server);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const token = createPageAuthToken({ scope: 'player', slug: 'cookie-test' });
const client = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/cookie-test`, {
headers: {
Cookie: `pulse_page_auth=${encodeURIComponent(token)}`
}
});
try {
await new Promise((resolve, reject) => {
client.once('open', resolve);
client.once('error', reject);
});
client.send(JSON.stringify({ type: 'state', clientId: 'cookie-client', clientName: 'Cookie Player', deviceId: 'device-cookie' }));
await waitFor(() => {
const snapshot = runtime.snapshotConnections('cookie-test')[0];
return snapshot && snapshot.clientName === 'Cookie Player' ? snapshot : null;
});
assert.equal(runtime.snapshotConnections('cookie-test')[0].clientName, 'Cookie Player');
} finally {
client.close();
await new Promise((resolve) => server.close(resolve));
}
});
test('player runtime sends targeted and broadcast commands to live sockets', async () => {
process.env.PULSE_SIGNAGE_SHARED_SECRET = 'runtime-secret';
const runtime = createPlayerRuntime({ pool: null });
const server = http.createServer();
runtime.installWebsocket(server);
await new Promise((resolve) => server.listen(0, resolve));
const { port } = server.address();
const token = createPageAuthToken({ scope: 'player', slug: 'test2' });
const clientA = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
const clientB = new WebSocket(`ws://127.0.0.1:${port}/ws/screens/test2?auth=${encodeURIComponent(token)}`);
function openClient(client) {
return new Promise((resolve, reject) => {
client.once('open', resolve);
client.once('error', reject);
});
}
function waitForMessage(client) {
return new Promise((resolve) => {
client.once('message', (raw) => {
resolve(JSON.parse(String(raw)));
});
});
}
try {
await Promise.all([openClient(clientA), openClient(clientB)]);
clientA.send(JSON.stringify({ type: 'state', clientId: 'client-a', clientName: 'Alpha', deviceId: 'device-a', page: 'http://localhost:8081/screen/test2' }));
clientB.send(JSON.stringify({ type: 'state', clientId: 'client-b', clientName: 'Beta', deviceId: 'device-b', page: 'http://localhost:8081/screen/test2' }));
await waitFor(() => {
const connections = runtime.snapshotConnections('test2');
return connections.length === 2 && connections.every((connection) => connection.clientName);
});
const [firstConnection, secondConnection] = runtime.snapshotConnections('test2');
const targetedMessagePromise = waitForMessage(clientA);
const targetedCount = await runtime.sendCommandToConnection('test2', firstConnection.id, { action: 'pause' });
assert.equal(targetedCount, 1);
const targetedMessage = await targetedMessagePromise;
assert.equal(targetedMessage.type, 'command');
assert.equal(targetedMessage.action, 'pause');
assert.equal(targetedMessage.targetConnectionId, firstConnection.id);
const broadcastPromises = [waitForMessage(clientA), waitForMessage(clientB)];
const broadcastCount = await runtime.broadcastCommand('test2', 'resume');
assert.equal(broadcastCount, 2);
const [broadcastA, broadcastB] = await Promise.all(broadcastPromises);
assert.equal(broadcastA.type, 'command');
assert.equal(broadcastA.command, 'resume');
assert.equal(broadcastB.type, 'command');
assert.equal(broadcastB.command, 'resume');
assert.equal(broadcastA.targetConnectionId, undefined);
assert.equal(broadcastB.targetConnectionId, undefined);
assert.equal(await runtime.sendCommandToConnection('test2', 'missing', 'pause'), 0);
assert.equal(secondConnection.clientName, 'Beta');
} finally {
clientA.close();
clientB.close();
await new Promise((resolve) => server.close(resolve));
}
});
+103
View File
@@ -0,0 +1,103 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
createPageAuthBundle,
createPageAuthToken,
createPageFetchAuthScript,
createRequestAuthHeaders,
verifyPageAuthToken,
verifyRequestAuth,
PAGE_TOKEN_HEADER,
REQUEST_SIGNATURE_HEADER,
REQUEST_TIMESTAMP_HEADER
} = require('../src/request-auth');
const originalSecret = process.env.PULSE_SIGNAGE_SHARED_SECRET;
function setSecret(value) {
process.env.PULSE_SIGNAGE_SHARED_SECRET = value;
}
test.after(() => {
if (originalSecret === undefined) {
delete process.env.PULSE_SIGNAGE_SHARED_SECRET;
} else {
process.env.PULSE_SIGNAGE_SHARED_SECRET = originalSecret;
}
});
test('request auth headers verify empty parser bodies as null payloads', () => {
setSecret('test-secret');
const originalNow = Date.now;
Date.now = () => 1700000000000;
try {
const headers = createRequestAuthHeaders({ method: 'POST', pathname: '/api/media/config', body: null, timestamp: 1700000000000 });
const req = {
method: 'POST',
path: '/api/media/config',
body: {},
headers: {
[REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
[REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
}
};
assert.equal(verifyRequestAuth(req), true);
} finally {
Date.now = originalNow;
}
});
test('page auth bundles round-trip and expire as expected', () => {
setSecret('test-secret');
const originalNow = Date.now;
Date.now = () => 1700000000000;
try {
const bundle = createPageAuthBundle({ scope: 'player', slug: 'test2' });
assert.equal(typeof bundle.token, 'string');
assert.equal(bundle.issuedAt, 1700000000000);
assert.equal(bundle.expiresAt, 1700043200000);
assert.deepEqual(verifyPageAuthToken(bundle.token), {
scope: 'player',
slug: 'test2',
issuedAt: 1700000000000,
expiresAt: 1700043200000
});
assert.equal(createPageAuthToken({ scope: 'player' }).split('.').length, 2);
Date.now = () => 1700043200001;
assert.equal(verifyPageAuthToken(bundle.token), null);
} finally {
Date.now = originalNow;
}
});
test('request auth rejects mismatched signatures', () => {
setSecret('test-secret');
const headers = createRequestAuthHeaders({ method: 'PUT', pathname: '/api/media/config', body: { hello: 'world' }, timestamp: 1700000000000 });
const req = {
method: 'PUT',
path: '/api/media/config',
body: { hello: 'changed' },
headers: {
[REQUEST_TIMESTAMP_HEADER]: headers[REQUEST_TIMESTAMP_HEADER],
[REQUEST_SIGNATURE_HEADER]: headers[REQUEST_SIGNATURE_HEADER]
}
};
assert.equal(verifyRequestAuth(req), false);
assert.equal(PAGE_TOKEN_HEADER, 'x-pulse-page-auth');
});
test('page fetch auth script injects renew and header logic', () => {
const script = createPageFetchAuthScript({ token: 'abc123', expiresAt: 1700000000000 });
assert.match(script, /window\.__pulsePageAuthToken = pageAuthToken/);
assert.match(script, /"x-pulse-page-auth"/);
assert.match(script, /\/api\/auth\/page/);
assert.match(script, /abc123/);
});
+109
View File
@@ -0,0 +1,109 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const {
buildTemplatePayload,
extractTemplateRegions
} = require('../src/data/templates');
test('extractTemplateRegions normalizes JSON regions and filters invalid rows', () => {
const regions = extractTemplateRegions({
regions_json: JSON.stringify([
{
region_name: 'Hero',
region_type: 'TEXT',
lock_ratio: '16 : 9',
animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
x: '10',
y: '20',
width: '320',
height: '180',
z_index: '2'
},
{
region_name: ' ',
region_type: 'image'
}
])
});
assert.deepEqual(regions, [{
region_key: 'Hero',
region_type: 'TEXT',
label: 'Hero',
lock_ratio: '16:9',
animation_json: {
intro: { preset: 'fadeIn' },
outro: { preset: 'bounceOut' },
loop: { preset: 'none' }
},
x: 10,
y: 20,
width: 320,
height: 180,
z_index: 2
}]);
});
test('buildTemplatePayload resolves canvas size and rejects duplicate region names', async () => {
const pool = {
async query(sql, params) {
if (sql.includes('FROM c_canvas_sizes WHERE id = ?')) {
assert.deepEqual(params, [4]);
return [[{ id: 4, name: 'HD', width: 1280, height: 720 }]];
}
throw new Error(`unexpected query: ${sql}`);
}
};
const payload = await buildTemplatePayload(pool, {
body: {
name: ' Main Template ',
canvas_size_id: '4',
background_color: 'not-a-color',
regions_json: JSON.stringify([
{ region_name: 'Header', region_type: 'text', lock_ratio: '4:3' }
])
},
files: []
}, null);
assert.deepEqual(payload, {
name: 'Main Template',
canvasSizeId: 4,
canvasSizeWidth: 1280,
canvasSizeHeight: 720,
backgroundImagePath: null,
backgroundColor: '#111111',
regions: [{
region_key: 'Header',
region_type: 'text',
label: 'Header',
lock_ratio: '4:3',
animation_json: {
intro: { preset: 'none' },
outro: { preset: 'none' },
loop: { preset: 'none' }
},
x: 0,
y: 0,
width: 100,
height: 100,
z_index: 0
}]
});
await assert.rejects(
() => buildTemplatePayload(pool, {
body: {
name: 'Dupes',
regions_json: JSON.stringify([
{ region_name: 'One' },
{ region_name: 'one' }
])
},
files: []
}, null),
(error) => error && error.statusCode === 400 && error.message === 'Region names must be unique on this template.'
);
});
+96
View File
@@ -0,0 +1,96 @@
const test = require('node:test');
const assert = require('node:assert/strict');
const registerManageRoutes = require('../src/web/routes/admin/manage');
test('screen update redirects and forwards redirect when the slug changes', async () => {
const handlers = {};
const app = {
post(path, ...routeHandlers) {
handlers[path] = routeHandlers;
},
get() {}
};
const pool = {
async query(sql) {
if (sql.includes('SELECT slug FROM d_screens ORDER BY slug ASC')) {
return [[{ slug: 'alpha' }, { slug: 'beta' }]];
}
if (sql.includes('SELECT id, name, slug, playlist_id')) {
return [[{ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }]];
}
if (sql.includes('UPDATE d_screens SET name = ?, slug = ?, playlist_id = ?, player_id = ?, modified_by = ? WHERE id = ?')) {
return [{ affectedRows: 1 }];
}
return [[]];
}
};
const calls = [];
const pages = { renderScreenFormPage() {}, renderScreenEditPage() {} };
const deps = {
pool,
common: {
slugify(value) { return String(value || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, ''); },
uniqueScreenSlug: async () => 'beta',
fetchDuplicateName: async () => null,
fetchScreenById: async () => ({ id: 42, name: 'Old Screen', slug: 'alpha', playlist_id: null }),
fetchScreenPlayerRecord: async () => ({ public_base_url: 'http://player.local' })
},
pages,
getAuditUserId() { return 7; },
redirectAfterSave(req, res, url) {
calls.push({ kind: 'redirectAfterSave', url });
res.redirectedTo = url;
},
notifyPlayerScreens: async (slugs, payload) => {
calls.push({ kind: 'notifyPlayerScreens', slugs, payload });
return 1;
},
broadcastDashboardState: async () => {
calls.push({ kind: 'broadcastDashboardState' });
},
getScreenDeleteBlockMessage: async () => '',
getScreenConnections: async () => [],
forwardPlayerCommand: async (slug, payload) => {
calls.push({ kind: 'forwardPlayerCommand', slug, payload });
return { ok: true };
},
playerPublicBaseUrl: 'http://player.example',
requirePermission() {
return function (_req, _res, next) {
next();
};
}
};
registerManageRoutes(app, deps);
const routeHandlers = handlers['/screens/:id'];
assert.equal(Array.isArray(routeHandlers), true);
const req = {
params: { id: '42' },
body: { name: 'Updated Screen', slug: 'beta' }
};
const res = {
redirect(url) {
this.redirectedTo = url;
},
status() {
return this;
},
send() {
return this;
}
};
await routeHandlers[1](req, res, () => {});
assert.equal(res.redirectedTo, '/screens?edit=42');
assert.deepEqual(calls, [
{ kind: 'forwardPlayerCommand', slug: 'alpha', payload: { command: 'redirect', url: 'http://player.local/screen/beta' } },
{ kind: 'redirectAfterSave', url: '/screens?edit=42' }
]);
});