Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a45552fed3 | ||
|
|
a5e8ecb21f | ||
|
|
9d5029eff6 |
@@ -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*
|
||||
|
||||
@@ -2,6 +2,39 @@
|
||||
|
||||
All notable changes to this project will be documented in this file.
|
||||
|
||||
## 2.5.4 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- The API source bearer token field now uses a password input with a right-side toggle to show or hide the token.
|
||||
|
||||
### Fixed
|
||||
|
||||
- QR code regions now render at the correct size in the slide editor preview, and the QR chip header now matches the other region cards.
|
||||
- The dashboard onboarding link now uses the player public base URL stored in the database instead of a `/screen/...` URL.
|
||||
|
||||
## 2.5.3 - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- The user edit page delete action now shows a confirmation prompt before removing an account.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Schedule modal cancel now restores the original rule set instead of keeping edits made after the modal opened.
|
||||
|
||||
## 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
|
||||
|
||||
Generated
+9
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.5.1",
|
||||
"version": "2.5.4",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "2.5.1",
|
||||
"version": "2.5.4",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"animate.css": "^4.1.1",
|
||||
@@ -20,6 +20,7 @@
|
||||
"mysql2": "^3.14.3",
|
||||
"puppeteer-core": "^24.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"sharp": "^0.35.3",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
@@ -2708,6 +2709,12 @@
|
||||
"node": ">=10.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/qrcode-generator": {
|
||||
"version": "2.0.4",
|
||||
"resolved": "https://registry.npmjs.org/qrcode-generator/-/qrcode-generator-2.0.4.tgz",
|
||||
"integrity": "sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/qs": {
|
||||
"version": "6.15.3",
|
||||
"resolved": "https://registry.npmjs.org/qs/-/qs-6.15.3.tgz",
|
||||
|
||||
+2
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "2.5.1",
|
||||
"version": "2.5.4",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
@@ -29,6 +29,7 @@
|
||||
"mysql2": "^3.14.3",
|
||||
"puppeteer-core": "^24.16.0",
|
||||
"qrcode": "^1.5.4",
|
||||
"qrcode-generator": "^2.0.4",
|
||||
"sharp": "^0.35.3",
|
||||
"ws": "^8.21.0"
|
||||
},
|
||||
|
||||
+2
-1
@@ -22,9 +22,10 @@ async function fetchAdminData(pool) {
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [screens] = await pool.query(`
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||
SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name, pl.public_base_url
|
||||
FROM d_screens s
|
||||
LEFT JOIN c_playlists p ON p.id = s.playlist_id
|
||||
LEFT JOIN d_players pl ON pl.device_id = s.player_id
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
const QRCode = require('qrcode');
|
||||
|
||||
async function createQrCodeSvg(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return QRCode.toString(text, {
|
||||
type: 'svg',
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M'
|
||||
});
|
||||
}
|
||||
|
||||
async function buildQrCodeContent(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
const content = {
|
||||
type: 'qr-code',
|
||||
value: text
|
||||
};
|
||||
|
||||
if (text) {
|
||||
const svg = await createQrCodeSvg(text);
|
||||
if (svg) {
|
||||
content.qr_svg = svg;
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createQrCodeSvg,
|
||||
buildQrCodeContent
|
||||
};
|
||||
+23
-2
@@ -2,6 +2,7 @@
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { buildQrCodeContent } = require('./qr-code');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
const DEFAULT_FONT_SIZE = 32;
|
||||
@@ -142,6 +143,14 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'webpage',
|
||||
value: submitted === undefined ? current : String(submitted || '').trim()
|
||||
};
|
||||
} else if (region.region_type === 'qr-code') {
|
||||
const submitted = body[`region_qr_code_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim();
|
||||
content[region.region_key] = {
|
||||
type: 'qr-code',
|
||||
value: nextValue
|
||||
};
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
const submitted = body[`region_rtmp_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
@@ -202,7 +211,7 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
font_size: style.font_size,
|
||||
font_color: style.font_color
|
||||
};
|
||||
} else if (!['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) {
|
||||
} else if (!['text', 'image', 'video', 'webpage', 'qr-code', 'html', 'rtmp', 'rss', 'api'].includes(String(region.region_type || '').trim())) {
|
||||
const current = existingContent && existingContent[region.region_key] && typeof existingContent[region.region_key] === 'object' ? existingContent[region.region_key] : {};
|
||||
const suffix = '_' + region.id;
|
||||
const generic = {};
|
||||
@@ -273,10 +282,22 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
}
|
||||
|
||||
if (template) {
|
||||
const content = buildTemplateContent(template, req.body, filesByField, existingContent);
|
||||
await Promise.all(Object.keys(content).map(async function (regionKey) {
|
||||
const region = template.regions.find(function (item) {
|
||||
return String(item.region_key || '').trim() === regionKey;
|
||||
});
|
||||
if (!region || region.region_type !== 'qr-code') {
|
||||
return;
|
||||
}
|
||||
|
||||
content[regionKey] = await buildQrCodeContent(content[regionKey].value);
|
||||
}));
|
||||
|
||||
return {
|
||||
title,
|
||||
templateId: template.id,
|
||||
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent))
|
||||
contentJson: JSON.stringify(content)
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Vendored
+1
-1
@@ -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),
|
||||
|
||||
+40
-14
@@ -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');
|
||||
@@ -243,24 +234,59 @@ 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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -215,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 () {
|
||||
|
||||
@@ -214,6 +214,8 @@ body.thumbnail-preview .player-offline-banner {
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
transition: opacity 560ms ease;
|
||||
will-change: opacity;
|
||||
backface-visibility: hidden;
|
||||
}
|
||||
|
||||
.slide-shell.is-visible {
|
||||
@@ -243,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;
|
||||
@@ -440,6 +443,14 @@ body.screen-blackout #app {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-region.qr-code img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.template-region.html iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
@@ -526,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 () {
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
// QR code region rendering for embedded URL-to-image output.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderQrCodeRegion(region, regionContent) {
|
||||
var svg = String(regionContent && regionContent.qr_svg || '').trim();
|
||||
if (!svg) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region qr-code" style="' + region.baseStyle + '"><img class="template-region-qr-code-image" src="data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + '" alt="QR code" /></div>';
|
||||
}
|
||||
|
||||
registry.register('qr-code', {
|
||||
renderRegion: renderQrCodeRegion
|
||||
});
|
||||
@@ -132,7 +132,7 @@ function renderPlayerPage(slug, initialData) {
|
||||
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
@@ -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
@@ -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();',
|
||||
' });',
|
||||
|
||||
@@ -114,6 +114,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'qr-code') {
|
||||
const src = String(regionContent.qr_svg || rawValue || regionContent.qr_code || '').trim();
|
||||
return src
|
||||
? '<img src="data:image/svg+xml;charset=utf-8,' + encodeURIComponent(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '" />'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'html') {
|
||||
const html = String(rawValue || '').trim();
|
||||
return html
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = function registerMiddleware(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -11,10 +12,12 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
const uploadsDir = deps.UPLOADS_DIR;
|
||||
const thumbnailsDir = deps.THUMBNAILS_DIR;
|
||||
const assetDir = deps.ASSET_DIR;
|
||||
const qrcodeGeneratorDir = path.join(__dirname, '..', '..', 'node_modules', 'qrcode-generator', 'dist');
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/assets/vendor/qrcode-generator', express.static(qrcodeGeneratorDir));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
fs.mkdirSync(thumbnailsDir, { recursive: true });
|
||||
|
||||
@@ -1743,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;
|
||||
}
|
||||
@@ -1756,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 {
|
||||
@@ -1869,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 {
|
||||
@@ -1880,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);
|
||||
|
||||
@@ -210,6 +210,15 @@
|
||||
return savedMessage;
|
||||
}
|
||||
|
||||
function parseAsyncSaveErrorMessage(responseText) {
|
||||
var errorMessage = parseAsyncSaveResponseMessage(responseText);
|
||||
if (errorMessage) {
|
||||
return errorMessage;
|
||||
}
|
||||
|
||||
return String(responseText || '').trim();
|
||||
}
|
||||
|
||||
function getAsyncSaveActionField(form) {
|
||||
if (!form || !form.querySelector) {
|
||||
return null;
|
||||
@@ -302,7 +311,8 @@
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || (settings.errorMessage || 'Unable to save changes.'));
|
||||
var responseText = await response.text();
|
||||
throw new Error(parseAsyncSaveErrorMessage(responseText) || (settings.errorMessage || 'Unable to save changes.'));
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
|
||||
@@ -9,6 +9,29 @@
|
||||
var methodSelect = form.querySelector('[data-api-source-auth-method]');
|
||||
var authDetailsSection = form.querySelector('[data-api-source-auth-details-section]');
|
||||
var panels = Array.prototype.slice.call(form.querySelectorAll('[data-api-source-auth-panel]'));
|
||||
var bearerTokenInput = form.querySelector('[data-api-source-bearer-token-input]');
|
||||
var bearerTokenToggle = form.querySelector('[data-api-source-bearer-token-toggle]');
|
||||
|
||||
function updateBearerTokenToggle() {
|
||||
if (!bearerTokenInput || !bearerTokenToggle) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isVisible = bearerTokenInput.type === 'text';
|
||||
bearerTokenToggle.setAttribute('aria-pressed', String(isVisible));
|
||||
bearerTokenToggle.setAttribute('aria-label', isVisible ? 'Hide bearer token' : 'Show bearer token');
|
||||
bearerTokenToggle.innerHTML = '<i class="bi ' + (isVisible ? 'bi-eye-slash' : 'bi-eye') + '" aria-hidden="true"></i>';
|
||||
}
|
||||
|
||||
function toggleBearerTokenVisibility() {
|
||||
if (!bearerTokenInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
bearerTokenInput.type = bearerTokenInput.type === 'password' ? 'text' : 'password';
|
||||
updateBearerTokenToggle();
|
||||
bearerTokenInput.focus();
|
||||
}
|
||||
|
||||
function updatePanels() {
|
||||
var method = String(methodSelect && methodSelect.value || 'none').trim();
|
||||
@@ -28,5 +51,10 @@
|
||||
methodSelect.addEventListener('change', updatePanels);
|
||||
}
|
||||
|
||||
if (bearerTokenToggle && bearerTokenInput) {
|
||||
bearerTokenToggle.addEventListener('click', toggleBearerTokenVisibility);
|
||||
updateBearerTokenToggle();
|
||||
}
|
||||
|
||||
updatePanels();
|
||||
}());
|
||||
@@ -478,9 +478,18 @@
|
||||
});
|
||||
}
|
||||
|
||||
function closeScheduleModal() {
|
||||
function closeScheduleModal(options) {
|
||||
var dialog = document.getElementById('slide-schedule-dialog');
|
||||
var content = document.getElementById('slide-schedule-content');
|
||||
var shouldDiscardDraft = Boolean(options && options.discardDraft);
|
||||
var shouldPreserveChanges = Boolean(options && options.preserveChanges);
|
||||
|
||||
if (shouldDiscardDraft && !shouldPreserveChanges && typeof window.restorePlaylistScheduleDraft === 'function') {
|
||||
window.restorePlaylistScheduleDraft();
|
||||
}
|
||||
if (shouldDiscardDraft && typeof window.clearPlaylistScheduleDraft === 'function') {
|
||||
window.clearPlaylistScheduleDraft();
|
||||
}
|
||||
|
||||
if (content) {
|
||||
content.innerHTML = '';
|
||||
@@ -629,6 +638,7 @@
|
||||
var isDraftMode = Boolean(form.getAttribute('data-schedule-draft') === 'true');
|
||||
var rowKeyInput = form.querySelector('[name="row_key"]');
|
||||
var draftStore = window.__playlistScheduleDraftStore = window.__playlistScheduleDraftStore || {};
|
||||
var draftSnapshotStore = window.__playlistScheduleDraftSnapshotStore = window.__playlistScheduleDraftSnapshotStore || {};
|
||||
var cancelButton = scope.querySelector('[data-schedule-cancel]');
|
||||
|
||||
// Draft state is keyed by the row being edited so cancel/save can restore it later.
|
||||
@@ -659,6 +669,41 @@
|
||||
};
|
||||
}
|
||||
|
||||
function captureScheduleDraftState() {
|
||||
return getRuleCards().map(function (card) {
|
||||
return {
|
||||
rule: readRuleDraftFromCard(card),
|
||||
isCollapsed: Boolean(card && card.classList && card.classList.contains('collapsed-card'))
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
function renderScheduleDraftState(ruleStates) {
|
||||
var states = Array.isArray(ruleStates) ? ruleStates : [];
|
||||
|
||||
scheduleRuleList.innerHTML = '';
|
||||
states.forEach(function (ruleState, index) {
|
||||
var card = createRuleCardElement(ruleState && ruleState.rule ? ruleState.rule : {}, index + 1, { isCollapsed: Boolean(ruleState && ruleState.isCollapsed) });
|
||||
|
||||
if (card) {
|
||||
scheduleRuleList.appendChild(card);
|
||||
}
|
||||
});
|
||||
refreshRuleNumbers();
|
||||
syncScheduleRulesField();
|
||||
}
|
||||
|
||||
function restoreScheduleDraft() {
|
||||
var draftKey = getScheduleDraftKey();
|
||||
var snapshot = draftKey && Object.prototype.hasOwnProperty.call(draftSnapshotStore, draftKey) ? draftSnapshotStore[draftKey] : null;
|
||||
|
||||
if (!draftKey || !Array.isArray(snapshot)) {
|
||||
return;
|
||||
}
|
||||
|
||||
renderScheduleDraftState(snapshot);
|
||||
}
|
||||
|
||||
function getScheduleDraftRules() {
|
||||
var draftKey = getScheduleDraftKey();
|
||||
if (!draftKey || !Object.prototype.hasOwnProperty.call(draftStore, draftKey)) {
|
||||
@@ -687,9 +732,18 @@
|
||||
if (draftKey && Object.prototype.hasOwnProperty.call(draftStore, draftKey)) {
|
||||
delete draftStore[draftKey];
|
||||
}
|
||||
if (draftKey && Object.prototype.hasOwnProperty.call(draftSnapshotStore, draftKey)) {
|
||||
delete draftSnapshotStore[draftKey];
|
||||
}
|
||||
}
|
||||
|
||||
window.clearPlaylistScheduleDraft = clearScheduleDraft;
|
||||
window.restorePlaylistScheduleDraft = restoreScheduleDraft;
|
||||
|
||||
var draftKey = getScheduleDraftKey();
|
||||
if (draftKey && !Object.prototype.hasOwnProperty.call(draftSnapshotStore, draftKey)) {
|
||||
draftSnapshotStore[draftKey] = captureScheduleDraftState();
|
||||
}
|
||||
|
||||
function getRuleCards() {
|
||||
return Array.prototype.slice.call(scheduleRuleList.querySelectorAll('[data-schedule-rule-card]'));
|
||||
@@ -1047,11 +1101,7 @@
|
||||
|
||||
var draftRules = getScheduleDraftRules();
|
||||
if (draftRules !== null) {
|
||||
scheduleRuleList.innerHTML = '';
|
||||
draftRules.forEach(function (rule) {
|
||||
addRule(rule && rule.rule ? rule.rule : {}, { isCollapsed: Boolean(rule && rule.isCollapsed) });
|
||||
});
|
||||
syncScheduleRulesField();
|
||||
renderScheduleDraftState(draftRules);
|
||||
} else if (!getRuleCards().length) {
|
||||
syncScheduleRulesField();
|
||||
} else {
|
||||
|
||||
@@ -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>' +
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
// QR code region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
var utils = window.pulseRegionUtils || {};
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function buildQrCodeUrl(value) {
|
||||
var text = String(value && typeof value === 'object' ? (value.value !== undefined ? value.value : value.qr_code) : value || '').trim();
|
||||
var svg = String(value && typeof value === 'object' && value.qr_svg ? value.qr_svg : '').trim();
|
||||
if (svg) {
|
||||
return svg;
|
||||
}
|
||||
|
||||
if (!text || typeof window.qrcode !== 'function') {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
var code = window.qrcode(0, 'M');
|
||||
code.addData(text);
|
||||
code.make();
|
||||
svg = code.createSvgTag(4, 1);
|
||||
return svg || '';
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeQrSvg(svg) {
|
||||
var markup = String(svg || '').trim();
|
||||
if (!markup || markup.charAt(0) !== '<') {
|
||||
return markup;
|
||||
}
|
||||
|
||||
return markup.replace(/^<svg\b([^>]*)>/i, function (_match, attrText) {
|
||||
var attrs = String(attrText || '');
|
||||
if (!/\bwidth\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' width="95%"';
|
||||
}
|
||||
if (!/\bheight\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' height="95%"';
|
||||
}
|
||||
if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' preserveAspectRatio="xMidYMid meet"';
|
||||
}
|
||||
if (!/\bstyle\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' style="display:block;width:95%;height:95%;"';
|
||||
}
|
||||
return '<svg' + attrs + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function renderPreview(region, value) {
|
||||
var src = buildQrCodeUrl(value);
|
||||
if (!src) {
|
||||
return '<div class="slide-preview-placeholder">QR Code</div>';
|
||||
}
|
||||
|
||||
if (String(src || '').trim().charAt(0) === '<') {
|
||||
return '<div class="slide-preview-qr-code-frame" style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:#fff;overflow:hidden;">' + normalizeQrSvg(src) + '</div>';
|
||||
}
|
||||
|
||||
return '<img class="slide-preview-qr-code" src="' + escapeHtml(src) + '" alt="QR code preview" style="width:100%;height:100%;object-fit:contain;display:block;background:#fff;" />';
|
||||
}
|
||||
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
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">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' +
|
||||
'<span class="chip">QR Code</span>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-2">' +
|
||||
'<input type="url" name="region_qr_code_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">The QR code is generated from the URL above.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context, existingContent) {
|
||||
var existing = existingContent && context && context.region && existingContent[context.region.region_key] ? existingContent[context.region.region_key] : null;
|
||||
return {
|
||||
region: context.region,
|
||||
current: String(context.current || (existing && (existing.value !== undefined ? existing.value : existing.qr_code)) || ''),
|
||||
qr_svg: existing && existing.qr_svg ? existing.qr_svg : ''
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent) {
|
||||
var current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
var input = card && card.querySelector ? card.querySelector('input[type="url"][name="region_qr_code_' + region.id + '"]') : null;
|
||||
|
||||
return {
|
||||
value: input ? input.value : (current && current.value !== undefined ? current.value : (current && current.qr_code !== undefined ? current.qr_code : '')),
|
||||
existingContent: existingContent || {}
|
||||
};
|
||||
}
|
||||
|
||||
registry.register('qr-code', {
|
||||
label: 'QR Code',
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 240, height: 240 };
|
||||
},
|
||||
renderPreview: renderPreview,
|
||||
renderEditorCard: renderEditorCard,
|
||||
buildEditorCardContext: buildEditorCardContext,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext
|
||||
});
|
||||
}());
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -434,8 +434,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewPopupHash() {
|
||||
return encodeURIComponent(JSON.stringify(buildPreviewPopupPayload()));
|
||||
}
|
||||
|
||||
function buildPreviewPopupUrl() {
|
||||
return '/slides/popup-preview#' + encodeURIComponent(JSON.stringify(buildPreviewPopupPayload()));
|
||||
return '/slides/popup-preview#' + buildPreviewPopupHash();
|
||||
}
|
||||
|
||||
function syncPopupPreview() {
|
||||
@@ -444,7 +448,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return;
|
||||
}
|
||||
|
||||
previewPopupWindow = window.open(buildPreviewPopupUrl(), 'slide-preview-popup', previewPopupFeatures);
|
||||
previewPopupWindow.location.hash = buildPreviewPopupHash();
|
||||
}
|
||||
|
||||
function openPreviewPopup() {
|
||||
@@ -588,6 +592,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
? ' slide-preview-video-region'
|
||||
: region.region_type === 'webpage'
|
||||
? ' slide-preview-webpage-region'
|
||||
: region.region_type === 'qr-code'
|
||||
? ' slide-preview-qr-code-region'
|
||||
: region.region_type === 'rtmp'
|
||||
? ' slide-preview-rtmp-region'
|
||||
: region.region_type === 'rss'
|
||||
|
||||
@@ -52,6 +52,7 @@ function buildUsersEditViewModel(user, message, currentUser, roles) {
|
||||
footerCancelUrl: '/users',
|
||||
footerDeleteUrl: '/users/' + user.id + '/delete',
|
||||
footerDeleteDisabled: Boolean(user && user.inUse),
|
||||
footerDeleteConfirmMessage: 'Delete this user?',
|
||||
footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '',
|
||||
footerShowDelete: true,
|
||||
message: message,
|
||||
|
||||
@@ -2,7 +2,17 @@
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || '').trim().replace(/\/$/, '');
|
||||
}
|
||||
|
||||
module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
const primaryPlayerUrl = Array.isArray(data.screens)
|
||||
? String((data.screens.find(function (screen) {
|
||||
return screen && String(screen.public_base_url || '').trim();
|
||||
}) || {}).public_base_url || '').trim()
|
||||
: '';
|
||||
|
||||
return renderView('dashboard/index', {
|
||||
title: 'Dashboard',
|
||||
active: 'dashboard',
|
||||
@@ -13,6 +23,7 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
|
||||
clients: data.clients || [],
|
||||
slides: data.slides || [],
|
||||
connectedClientsCount: Number(data.connectedClientsCount || 0),
|
||||
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
|
||||
scripts: ['js/dashboard/dashboard-page.js']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -53,7 +53,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
|
||||
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}
|
||||
},
|
||||
assetVersion: assetVersion,
|
||||
slideEditorScripts: getRegionEditorScripts(assetVersion),
|
||||
slideEditorScripts: ['vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
|
||||
currentUser: currentUser || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
|
||||
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
|
||||
canvasSizes: canvasSizes || [],
|
||||
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])
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -59,7 +59,12 @@
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="bearer" hidden>
|
||||
<div class="col-12">
|
||||
<label for="api-source-auth-bearer-token" class="form-label">Bearer token</label>
|
||||
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" autocomplete="off" />
|
||||
<div class="input-group">
|
||||
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" autocomplete="off" spellcheck="false" autocapitalize="off" autocorrect="off" data-api-source-bearer-token-input />
|
||||
<button type="button" class="btn btn-outline-secondary" aria-label="Show bearer token" aria-pressed="false" data-api-source-bearer-token-toggle>
|
||||
<i class="bi bi-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="api_key_header" hidden>
|
||||
|
||||
@@ -26,7 +26,7 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,13 @@
|
||||
<span class="dashboard-hero-kicker">Live overview</span>
|
||||
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
|
||||
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen snapshot when you want a quick read on playlist assignment and live connections.</p>
|
||||
{{#if primaryPlayerUrl}}
|
||||
<div class="alert alert-info mb-0 mt-3 py-2">
|
||||
<strong>Begin onboarding:</strong>
|
||||
open <a href="{{primaryPlayerUrl}}" target="_blank" rel="noreferrer">{{primaryPlayerUrl}}</a>
|
||||
on the public screen to get started.
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="dashboard-hero-stats">
|
||||
{{#if (hasPermission currentUser "playlists.read")}}
|
||||
|
||||
@@ -120,6 +120,7 @@
|
||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
||||
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
|
||||
<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
|
||||
<script src="/assets/vendor/qrcode-generator/qrcode.js?v={{appVersion}}"></script>
|
||||
{{#each slideEditorScripts}}
|
||||
<script type="module" src="/assets/{{this}}?v={{../appVersion}}"></script>
|
||||
{{/each}}
|
||||
|
||||
@@ -125,6 +125,8 @@
|
||||
<form id="delete-template-form" method="post" action="/templates/{{template.id}}/delete" data-confirm-message="Delete this template?"></form>
|
||||
{{/if}}
|
||||
|
||||
<script src="/assets/vendor/qrcode-generator/qrcode.js?v={{appVersion}}"></script>
|
||||
|
||||
{{#> modal-shell modalId="region-add-modal" modalLabelId="region-add-modal-label" modalDialogClass="modal-dialog-centered"}}
|
||||
<div class="modal-header">
|
||||
<h2 class="modal-title fs-5" id="region-add-modal-label">Add region</h2>
|
||||
@@ -138,7 +140,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>
|
||||
|
||||
@@ -87,6 +87,42 @@ test('player runtime snapshots websocket state and checks live names', async ()
|
||||
}
|
||||
});
|
||||
|
||||
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';
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { createQrCodeSvg, buildQrCodeContent } = require('../src/data/qr-code');
|
||||
const { buildSlidePayload } = require('../src/data/slides');
|
||||
|
||||
test('createQrCodeSvg renders a qr svg for non-empty values', async () => {
|
||||
const svg = await createQrCodeSvg('https://example.com');
|
||||
|
||||
assert.equal(typeof svg, 'string');
|
||||
assert.match(svg, /^<svg[\s>]/);
|
||||
});
|
||||
|
||||
test('buildQrCodeContent stores qr svg content for qr-code regions', async () => {
|
||||
const content = await buildQrCodeContent('https://example.com');
|
||||
|
||||
assert.equal(content.type, 'qr-code');
|
||||
assert.equal(content.value, 'https://example.com');
|
||||
assert.equal(typeof content.qr_svg, 'string');
|
||||
assert.match(content.qr_svg, /^<svg[\s>]/);
|
||||
});
|
||||
|
||||
test('buildSlidePayload stores qr-code slide content from the submitted url', async () => {
|
||||
const pool = {
|
||||
async query(sql) {
|
||||
if (sql.includes('FROM c_templates st')) {
|
||||
return [[{ id: 7, name: 'Template 7', canvas_size_id: 1, canvas_size_width: 1920, canvas_size_height: 1080 }]];
|
||||
}
|
||||
|
||||
if (sql.includes('FROM c_template_regions')) {
|
||||
return [[{ id: 22, template_id: 7, region_key: 'qrRegion', region_type: 'qr-code', label: 'QR' }]];
|
||||
}
|
||||
|
||||
return [[]];
|
||||
}
|
||||
};
|
||||
|
||||
const payload = await buildSlidePayload(pool, {
|
||||
body: {
|
||||
title: 'QR Slide',
|
||||
template_id: '7',
|
||||
region_qr_code_22: 'https://example.com'
|
||||
},
|
||||
files: []
|
||||
}, null);
|
||||
|
||||
const content = JSON.parse(payload.contentJson);
|
||||
assert.equal(content.qrRegion.type, 'qr-code');
|
||||
assert.equal(content.qrRegion.value, 'https://example.com');
|
||||
assert.equal(typeof content.qrRegion.qr_svg, 'string');
|
||||
assert.match(content.qrRegion.qr_svg, /^<svg[\s>]/);
|
||||
});
|
||||
@@ -0,0 +1,20 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { buildUsersEditViewModel, buildUsersAddViewModel } = require('../src/web/routes/settings/users/form-view-model');
|
||||
|
||||
test('user edit view model includes delete confirmation text', () => {
|
||||
const model = buildUsersEditViewModel({ id: 12, inUse: false, roleIds: [] }, 'Saved.', { id: 1 }, []);
|
||||
|
||||
assert.equal(model.footerDeleteUrl, '/users/12/delete');
|
||||
assert.equal(model.footerDeleteConfirmMessage, 'Delete this user?');
|
||||
assert.equal(model.footerDeleteDisabled, false);
|
||||
});
|
||||
|
||||
test('user add view model keeps delete disabled without a confirm message', () => {
|
||||
const model = buildUsersAddViewModel('Saved.', { id: 1 }, [], { name: 'New User', username: 'newuser' }, 'primary');
|
||||
|
||||
assert.equal(model.footerDeleteDisabled, true);
|
||||
assert.equal(model.footerDeleteUrl, '');
|
||||
assert.equal(model.footerDeleteConfirmMessage, undefined);
|
||||
});
|
||||
@@ -0,0 +1,25 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const renderDashboardPage = require('../src/web/routes/signage/dashboard');
|
||||
|
||||
test('dashboard onboarding link uses the player base url', () => {
|
||||
const html = renderDashboardPage(
|
||||
{
|
||||
screens: [
|
||||
{
|
||||
public_base_url: 'http://player.local/'
|
||||
}
|
||||
],
|
||||
playlists: [],
|
||||
clients: [],
|
||||
slides: [],
|
||||
connectedClientsCount: 0
|
||||
},
|
||||
'',
|
||||
null
|
||||
);
|
||||
|
||||
assert.match(html, /href="http:\/\/player\.local"/);
|
||||
assert.doesNotMatch(html, /http:\/\/player\.local\//);
|
||||
});
|
||||
Reference in New Issue
Block a user