Add onboarding weather and template gradients
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile, web, pulse-signage-web) (push) Successful in 1m53s
Publish Docker Image / build-and-push-existing-registry (./build/Dockerfile.player, player, pulse-signage-player) (push) Successful in 34s

This commit is contained in:
2026-08-28 20:05:23 +01:00
parent aa07a78912
commit 3960931ebe
80 changed files with 12750 additions and 519 deletions
+2 -2
View File
@@ -6,7 +6,7 @@ async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
@@ -112,7 +112,7 @@ async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDi
async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
(SELECT COUNT(*) FROM c_template_regions str WHERE str.template_id = st.id) AS region_count,
(SELECT COUNT(*) FROM c_slides s WHERE s.template_id = st.id) AS slide_count
+41 -2
View File
@@ -14,6 +14,39 @@ function sanitizeBackgroundColor(value) {
return '#111111';
}
function normalizeBackgroundGradient(value) {
let gradient = value;
if (typeof gradient === 'string') {
try {
gradient = JSON.parse(gradient);
} catch (_error) {
gradient = null;
}
}
if (!gradient || typeof gradient !== 'object' || Array.isArray(gradient)) {
return null;
}
const sourceStops = Array.isArray(gradient.stops) && gradient.stops.length
? gradient.stops
: (Array.isArray(gradient.colors) ? gradient.colors.map((color, index, colors) => ({
color,
position: colors.length > 1 ? Math.round((index / (colors.length - 1)) * 100) : 0
})) : []);
const stops = sourceStops.slice(0, 12).map((stop) => ({
color: sanitizeBackgroundColor(stop && stop.color),
position: Math.max(0, Math.min(100, Number.isFinite(Number(stop && stop.position)) ? Number(stop.position) : 0))
}));
if (stops.length < 2) {
return null;
}
const angle = Number(gradient.angle);
return JSON.stringify({
type: 'linear',
stops,
angle: Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90
});
}
function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim();
return rawType || 'text';
@@ -106,7 +139,7 @@ function ensureUniqueTemplateRegionNames(regions) {
async function fetchTemplateById(pool, id) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
@@ -123,7 +156,7 @@ async function fetchTemplateById(pool, id) {
async function fetchTemplatesData(pool) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
@@ -212,6 +245,10 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
const backgroundImage = filesByField.background_image;
const removeBackgroundImage = Boolean(req.body.remove_background_image);
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
const submittedBackgroundGradient = Object.prototype.hasOwnProperty.call(req.body, 'background_gradient')
? req.body.background_gradient
: existingTemplate && existingTemplate.background_gradient;
const backgroundGradient = normalizeBackgroundGradient(submittedBackgroundGradient);
const backgroundImagePath = backgroundImage
? `/media/uploads/${backgroundImage.filename}`
: removeBackgroundImage
@@ -256,6 +293,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
canvasSizeHeight: canvasHeight,
backgroundImagePath,
backgroundColor,
backgroundGradient,
regions
};
}
@@ -265,5 +303,6 @@ module.exports = {
fetchTemplatesData,
extractTemplateRegions,
buildTemplatePayload,
normalizeBackgroundGradient,
parseJsonSafe
};
+1 -1
View File
@@ -89,7 +89,7 @@ async function fetchWeatherLocationForecast(pool, location) {
if (!apiKey) throw new Error('Pirate Weather API key is not configured.');
url = 'https://api.pirateweather.net/forecast/' + encodeURIComponent(apiKey) + '/' + latitude + ',' + longitude + '?units=' + (temperatureUnit === 'fahrenheit' ? 'us' : 'si');
} else {
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
const params = new URLSearchParams({ latitude: String(latitude), longitude: String(longitude), timezone: String(source.timezone || 'auto'), forecast_days: '7', forecast_hours: '24', current: 'temperature_2m,relative_humidity_2m,apparent_temperature,is_day,precipitation,rain,weather_code,wind_speed_10m,wind_direction_10m,uv_index,cloud_cover', hourly: 'temperature_2m,precipitation_probability,precipitation,weather_code,wind_speed_10m,uv_index,cloud_cover', daily: 'weather_code,temperature_2m_max,temperature_2m_min,sunrise,sunset,precipitation_probability_max,precipitation_sum,wind_speed_10m_max,uv_index_max,cloud_cover_mean', temperature_unit: temperatureUnit, wind_speed_unit: windUnit, precipitation_unit: precipitationUnit });
const apiKey = String(settings['weather.open_meteo_api_key'] || '').trim();
if (apiKey) params.set('apikey', apiKey);
url = 'https://api.open-meteo.com/v1/forecast?' + params.toString();
+2 -1
View File
@@ -17,7 +17,8 @@ function createPool() {
async function pruneStaleOnboardingDevices(pool) {
await pool.query(
`DELETE FROM d_onboarding_devices
WHERE modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
WHERE screen_id IS NULL
AND modified_at < (CURRENT_TIMESTAMP - INTERVAL 1 MINUTE)`
);
}
+1
View File
@@ -57,6 +57,7 @@ async function ensureSchema(pool, options) {
canvas_size_id INT NULL,
background_image_path VARCHAR(512) NULL,
background_color VARCHAR(32) NULL,
background_gradient LONGTEXT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
+8 -1
View File
@@ -541,10 +541,17 @@ const VERSIONED_MIGRATIONS = [
version: '2.8.9',
label: 'v2.8.9 data source enablement schema',
run: async function (pool) {
await ensureColumn(pool, 'i_api_sources', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'items_path');
await ensureColumn(pool, 'i_api_sources', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'api_url');
await ensureColumn(pool, 'i_rss_feeds', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'feed_url');
await ensureColumn(pool, 'i_weather_locations', 'enabled', 'TINYINT(1) NOT NULL DEFAULT 1', 'precipitation_unit');
}
},
{
version: '2.10.1',
label: 'v2.10.1 template background gradient schema',
run: async function (pool) {
await ensureColumn(pool, 'c_templates', 'background_gradient', 'LONGTEXT NULL', 'background_color');
}
}
];
+172 -22
View File
@@ -13,6 +13,7 @@ const { commitDeviceBinding, bindPlayerToScreen, getOnboardingStatus, getPlayerP
const { createStyledQrCodeSvg } = require('../data/qr-code');
const { verifyPageAuthToken } = require('#src/request-auth');
function createThinClientConfig() {
return {
port: Number(process.env.THIN_CLIENT_PORT || 8090),
@@ -354,6 +355,27 @@ async function start() {
return socket && socket.playerDeviceId ? String(socket.playerDeviceId).trim() : '';
}
function findPlayerByPairingCode(pairingCode) {
const normalizedCode = String(pairingCode || '').trim().toUpperCase();
if (!normalizedCode) {
return null;
}
for (const [deviceId, socket] of playerSockets.entries()) {
const pairingCodes = socket && Array.isArray(socket.pairingSessions)
? socket.pairingSessions.map(function (entry) { return entry.code; })
: (socket && Array.isArray(socket.pairingCodes) ? socket.pairingCodes : [socket && socket.pairingCode]);
if (socket && pairingCodes.some(function (code) {
return String(code || '').trim().toUpperCase() === normalizedCode;
})) {
const session = socket.pairingSessions && socket.pairingSessions.find(function (entry) {
return String(entry.code || '').trim().toUpperCase() === normalizedCode;
});
return { deviceId: deviceId, socket: socket, clientId: session && session.clientId ? session.clientId : null, code: normalizedCode };
}
}
return null;
}
function removeConnectedPlayerSocket(socket) {
if (!socket || !socket.playerDeviceId) {
return false;
@@ -514,6 +536,21 @@ async function start() {
};
}
function requireOnboardingAuth(req, res, next) {
const token = String(req.headers['x-pulse-page-auth'] || '').trim();
const payload = verifyPageAuthToken(token);
if (payload && ['onboarding', 'player'].indexOf(String(payload.scope || '').trim()) !== -1) {
req.playerPageAuth = payload;
return next();
}
if (verifyRequestAuth(req)) {
return next();
}
return res.status(401).json({ error: 'Onboarding authentication required.' });
}
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
res.json({
mediaDir: config.mediaDir,
@@ -630,12 +667,45 @@ async function start() {
}
});
app.post('/api/screens/:slug/announcements/refresh', requireRequestAuth, function (req, res) {
const slug = String(req.params.slug || '').trim();
if (!slug) {
return res.status(400).json({ error: 'Screen slug is required.' });
}
const targetPlayers = Array.from(playerSockets.values()).filter(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
}).map(function (socket) {
return { socket: socket };
});
let sent = 0;
targetPlayers.forEach(function (target) {
if (!target || !target.socket || target.socket.readyState !== WebSocket.OPEN) {
return;
}
try {
target.socket.send(JSON.stringify({
type: 'command',
command: 'announcement-refresh',
screenSlug: slug,
sentAt: new Date().toISOString()
}));
sent += 1;
} catch (_error) {
}
});
return res.json({ ok: true, screenSlug: slug, sent: sent });
});
app.get('/api/onboarding/status', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId();
const requestedDeviceId = normalizeDeviceId(req.query.deviceId);
const deviceId = requestedDeviceId;
const status = await getOnboardingStatus(pool, deviceId);
res.json({
deviceId: normalizeDeviceId(deviceId),
deviceId: requestedDeviceId,
onboarded: Boolean(status && status.screen_id),
clientName: status ? status.client_name : null,
screenId: status ? status.screen_id : null,
@@ -648,6 +718,58 @@ async function start() {
}
});
app.get('/api/onboarding/resolve', requireRequestAuth, function (req, res) {
const pairing = findPlayerByPairingCode(req.query.pairingCode);
if (!pairing) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
res.json({
deviceId: pairing.deviceId,
clientId: pairing.clientId || null,
});
});
app.get('/api/onboarding/url', requireRequestAuth, function (req, res) {
const pairing = findPlayerByPairingCode(req.query.pairingCode);
if (!pairing) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
const webBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '');
if (!webBaseUrl) {
return res.status(503).json({ error: 'WEB_PUBLIC_URL is not configured on the bridge.' });
}
res.json({ url: `${webBaseUrl}/pairing?code=${encodeURIComponent(pairing.code)}` });
});
app.get('/api/onboarding/qr', requireRequestAuth, async function (req, res, next) {
try {
const pairing = findPlayerByPairingCode(req.query.pairingCode);
if (!pairing) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
const webBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '');
if (!webBaseUrl) {
return res.status(503).json({ error: 'WEB_PUBLIC_URL is not configured on the bridge.' });
}
const svg = await createStyledQrCodeSvg({
value: `${webBaseUrl}/pairing?code=${encodeURIComponent(pairing.code)}`,
qr_margin: 20,
qr_dots_type: 'dots',
qr_dots_color: '#f4f8f5',
qr_corners_square_type: 'dot',
qr_corners_square_color: '#f4f8f5',
qr_corners_dot_type: 'dot',
qr_corners_dot_color: '#f0bd70',
qr_background_transparent: true
});
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
} catch (error) {
next(error);
}
});
app.get('/api/onboarding/screens', requirePageAuth(['onboarding', 'player']), async function (_req, res, next) {
try {
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
@@ -657,25 +779,18 @@ async function start() {
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || getConnectedPlayerDeviceId();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
const pairingCode = String(req.body && req.body.pairingCode || '').trim().toUpperCase();
const pairing = findPlayerByPairingCode(pairingCode);
const clientId = normalizeDeviceId(req.body && req.body.clientId);
if (!pairing) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
const onboardingUrl = `${getPlayerPublicBaseUrl(req)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
} catch (error) {
next(error);
}
});
app.post('/api/onboarding', requirePageAuth(['onboarding', 'player']), express.json(), async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || getConnectedPlayerDeviceId();
if (!clientId) {
return res.status(400).json({ error: 'Client ID is required.' });
}
const deviceId = pairing.deviceId;
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
if (!deviceId) {
@@ -688,8 +803,13 @@ async function start() {
return res.status(400).json({ error: 'Screen is required' });
}
const status = await commitDeviceBinding(pool, deviceId, clientName, screenSlug, null, []);
const status = await commitDeviceBinding(pool, clientId, clientName, screenSlug, null, []);
await bindPlayerToScreen(pool, deviceId, screenSlug);
await sendPlayerCommandToSocket(pairing.socket, {
command: 'redirect',
url: `${String(pairing.socket.publicBaseUrl || getPlayerPublicBaseUrl(req)).replace(/\/$/, '')}/screen/${encodeURIComponent(screenSlug)}`,
clientId: clientId
});
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
@@ -709,6 +829,11 @@ async function start() {
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
const status = await getOnboardingStatus(pool, clientId);
if (!clientId || !status || String(status.screen_slug || '').trim() !== String(req.params.slug || '').trim()) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const data = await playerPlaylistService.buildScreenPlaylist(req.params.slug);
if (!data.screen) {
@@ -729,6 +854,11 @@ async function start() {
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try {
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
const status = await getOnboardingStatus(pool, clientId);
if (!clientId || !status || String(status.screen_slug || '').trim() !== String(req.params.slug || '').trim()) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
const announcement = typeof common.fetchActiveAnnouncement === 'function'
? await common.fetchActiveAnnouncement(pool, req.params.slug)
@@ -841,6 +971,17 @@ async function start() {
}
socket.playerDeviceId = deviceId;
socket.pairingSessions = Array.isArray(payload.pairingSessions) ? payload.pairingSessions.map(function (entry) {
return {
deviceId: normalizeDeviceId(entry && entry.deviceId),
clientId: normalizeDeviceId(entry && entry.clientId),
code: String(entry && entry.code || '').trim().toUpperCase()
};
}).filter(function (entry) { return entry.deviceId && entry.code; }) : [];
socket.pairingCodes = Array.from(new Set((Array.isArray(payload.pairingCodes) ? payload.pairingCodes : [payload.pairingCode]).map(function (value) {
return String(value || '').trim().toUpperCase();
}).filter(Boolean)));
socket.pairingCode = socket.pairingCodes[0] || '';
if (messageType === 'snapshot') {
const slug = String(payload.slug || '').trim();
@@ -853,19 +994,28 @@ async function start() {
}
if (messageType === 'register') {
socket.publicBaseUrl = String(payload.publicBaseUrl || '').trim().replace(/\/$/, '');
const player = await upsertPlayerRegistration(pool, {
deviceId: deviceId,
publicBaseUrl: payload.publicBaseUrl,
internalBaseUrl: payload.internalBaseUrl
});
const previousSocket = playerSockets.get(deviceId);
playerSockets.set(deviceId, socket);
if (previousSocket && previousSocket !== socket && previousSocket.readyState !== WebSocket.CLOSED) {
try {
previousSocket.close(1000, 'Replaced by a newer player connection.');
} catch (_error) {
}
}
logBridge(`Player ${formatPlayerConnectionLabel(deviceId)} has connected`);
socket.send(JSON.stringify({ type: 'registered', ok: true, player: player }));
return;
}
if (messageType === 'heartbeat') {
socket.publicBaseUrl = String(payload.publicBaseUrl || socket.publicBaseUrl || '').trim().replace(/\/$/, '');
const player = await recordPlayerHeartbeat(pool, {
deviceId: deviceId,
publicBaseUrl: payload.publicBaseUrl,
@@ -928,15 +1078,15 @@ async function start() {
});
socket.on('close', function () {
clearPlayerSnapshotSources(socket.playerDeviceId);
if (removeConnectedPlayerSocket(socket)) {
clearPlayerSnapshotSources(socket.playerDeviceId);
logPlayerDisconnect(socket);
}
});
socket.on('error', function () {
clearPlayerSnapshotSources(socket.playerDeviceId);
if (removeConnectedPlayerSocket(socket)) {
clearPlayerSnapshotSources(socket.playerDeviceId);
logPlayerDisconnect(socket);
}
});
+23 -4
View File
@@ -37,6 +37,9 @@ async function start() {
let lastDisconnectAt = 0;
let playerPublicBaseUrl = PLAYER_PUBLIC_URL || null;
let refreshThinClientRegistration = null;
let activePairingCode = '';
let activePairingCodes = [];
let activePairingSessions = [];
const playerRuntime = createPlayerRuntime({
pool: pool,
normalizeDeviceId: normalizeDeviceId,
@@ -262,7 +265,7 @@ async function start() {
}
response.ok = true;
}
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname'].indexOf(command) !== -1) {
} else if (['refresh', 'reload', 'redirect', 'pause', 'blackout', 'previous', 'next', 'setclientname', 'announcement-refresh'].indexOf(command) !== -1) {
const screenSlug = String(payload.screenSlug || payload.slug || '').trim();
if (!screenSlug) {
response.error = 'Screen slug is required.';
@@ -305,7 +308,17 @@ async function start() {
playerPublicBaseUrl: getPlayerPublicBaseUrl(),
playerInternalBaseUrl: PLAYER_INTERNAL_URL,
bridgeBaseUrl: BRIDGE_PUBLIC_URL,
playerDeviceId: PLAYER_DEVICE_ID
playerDeviceId: PLAYER_DEVICE_ID,
onPairingCode: function (code, codes) {
activePairingCode = String(code || '').trim().toUpperCase();
activePairingSessions = Array.isArray(codes) ? codes.map(function (entry) {
return { deviceId: String(entry && entry.deviceId || '').trim(), clientId: String(entry && entry.clientId || '').trim(), code: String(entry && entry.code || '').trim().toUpperCase() };
}).filter(function (entry) { return entry.deviceId && entry.code; }) : [];
activePairingCodes = activePairingSessions.map(function (entry) { return entry.code; });
if (typeof refreshThinClientRegistration === 'function') {
refreshThinClientRegistration();
}
}
});
registerPlayerRoutes(app, {
pool: pool,
@@ -375,7 +388,10 @@ async function start() {
type: 'heartbeat',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL
internalBaseUrl: PLAYER_INTERNAL_URL,
pairingCode: activePairingCode,
pairingCodes: activePairingCodes,
pairingSessions: activePairingSessions
}));
}
@@ -439,7 +455,10 @@ async function start() {
type: 'register',
deviceId: PLAYER_DEVICE_ID,
publicBaseUrl: getPlayerPublicBaseUrl(),
internalBaseUrl: PLAYER_INTERNAL_URL
internalBaseUrl: PLAYER_INTERNAL_URL,
pairingCode: activePairingCode,
pairingCodes: activePairingCodes,
pairingSessions: activePairingSessions
}));
playerRuntime.snapshotSlugs().forEach(function (slug) {
+186 -13
View File
@@ -1,20 +1,44 @@
// Player onboarding routes and signup flow helpers.
const express = require('express');
const crypto = require('crypto');
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
const { getSharedSecret, verifyPageAuthToken, createRequestAuthHeaders } = require('#src/request-auth');
const { getSharedSecret, verifyPageAuthToken, verifyRequestAuth, createRequestAuthHeaders } = require('#src/request-auth');
const { resolvePlayerRegistration, upsertPlayerRegistration: upsertPlayerRegistrationRecord } = require('#src/data/player-registry');
const { isTransientDbError } = require('./store');
const ONBOARDING_SIGNUP_LIMIT_WINDOW_MS = 5 * 60 * 1000;
const ONBOARDING_SIGNUP_LIMIT_MAX_ATTEMPTS = 8;
const PAIRING_CODE_LENGTH = 6;
const PAIRING_CODE_TTL_MS = 15 * 60 * 1000;
const PAIRING_CODE_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
const onboardingSignupAttempts = new Map();
function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
}
function createPairingCode() {
const bytes = crypto.randomBytes(PAIRING_CODE_LENGTH);
let code = '';
for (let index = 0; index < PAIRING_CODE_LENGTH; index += 1) {
code += PAIRING_CODE_ALPHABET[bytes[index] % PAIRING_CODE_ALPHABET.length];
}
return code;
}
function createPairingSession(deviceId, clientId) {
return { deviceId: normalizeDeviceId(deviceId), clientId: normalizeDeviceId(clientId), code: createPairingCode(), expiresAt: Date.now() + PAIRING_CODE_TTL_MS };
}
function isValidOnboardingPairingCode(pairingSession, pairingCode, now) {
const suppliedCode = Buffer.from(String(pairingCode || '').trim().toUpperCase());
const expectedCode = Buffer.from(String(pairingSession && pairingSession.code || '').trim());
const currentTime = Number(now || Date.now());
return Boolean(pairingSession && pairingSession.deviceId && pairingSession.expiresAt > currentTime && suppliedCode.length === expectedCode.length && suppliedCode.length > 0 && crypto.timingSafeEqual(suppliedCode, expectedCode));
}
function getPublicBaseUrl(req, configuredUrl) {
const forwardedProto = String(req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
const protocol = forwardedProto || (req.socket && req.socket.encrypted ? 'https' : 'http');
@@ -120,7 +144,7 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
}
const screen = screenRows[0];
const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
const available = await isClientNameAvailable(pool, normalizedClientName, null, liveConnections);
if (!available) {
const error = new Error('Client name already exists.');
error.statusCode = 400;
@@ -217,6 +241,38 @@ function registerPlayerOnboardingRoutes(app, options) {
const playerPublicUrl = String(options && options.playerPublicBaseUrl || process.env.PLAYER_PUBLIC_URL || process.env.PLAYER_BASE_URL || '').trim().replace(/\/$/, '');
const bridgeBaseUrl = String(options && options.bridgeBaseUrl || process.env.BRIDGE_PUBLIC_URL || '').trim().replace(/\/$/, '');
const playerDeviceId = normalizeDeviceId(options && options.playerDeviceId);
const onPairingCode = options && typeof options.onPairingCode === 'function' ? options.onPairingCode : null;
const pairingSessions = new Map();
function getPairingSession(deviceId, clientId) {
const normalizedDeviceId = normalizeDeviceId(deviceId) || playerDeviceId;
if (!normalizedDeviceId) {
return null;
}
const sessionKey = `${normalizedDeviceId}:${normalizeDeviceId(clientId) || 'default'}`;
let pairingSession = pairingSessions.get(sessionKey);
if (!isValidOnboardingPairingCode(pairingSession, pairingSession && pairingSession.code)) {
pairingSession = createPairingSession(normalizedDeviceId, clientId);
pairingSessions.set(sessionKey, pairingSession);
}
if (onPairingCode) {
onPairingCode(pairingSession.code, Array.from(pairingSessions.entries()).filter(function (entry) {
return isValidOnboardingPairingCode(entry[1], entry[1] && entry[1].code);
}).map(function (entry) {
return { deviceId: entry[1].deviceId, clientId: entry[1].clientId || null, code: entry[1].code };
}));
}
return pairingSession;
}
function findPairingSession(pairingCode) {
for (const [deviceId, pairingSession] of pairingSessions.entries()) {
if (isValidOnboardingPairingCode(pairingSession, pairingCode)) {
return { deviceId: pairingSession.deviceId, session: pairingSession };
}
}
return null;
}
if (!app || !common) {
throw new Error('registerPlayerOnboardingRoutes requires app and common.');
@@ -289,15 +345,71 @@ function registerPlayerOnboardingRoutes(app, options) {
next();
}
app.get('/', function (_req, res) {
function requireOnboardingAuth(req, res, next) {
if (!sharedSecret) {
return next();
}
const pageToken = String(req.headers['x-pulse-page-auth'] || '').trim();
const pagePayload = verifyPageAuthToken(pageToken);
if (pagePayload && String(pagePayload.scope || '').trim() === 'onboarding') {
req.playerPageAuth = pagePayload;
return next();
}
if (verifyRequestAuth(req)) {
return next();
}
return res.status(401).json({ error: 'Onboarding authentication required.' });
}
app.get('/', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerOnboardingLandingPage());
try {
const deviceId = normalizeDeviceId(req.query && req.query.clientId);
let status = null;
if (deviceId) {
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId), {
method: 'GET'
});
status = await readJsonResponse(response);
} else {
status = await getOnboardingStatus(pool, deviceId);
}
}
const screenId = status && (status.screen_id || status.screenId);
const screenSlug = status && (status.screen_slug || status.screenSlug);
if (screenId && screenSlug) {
return res.redirect('/screen/' + encodeURIComponent(screenSlug));
}
res.send(common.renderPlayerOnboardingLandingPage({ pairingCode: '' }));
} catch (error) {
next(error);
}
});
app.get('/onboard', async function (req, res, next) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
try {
res.send(common.renderPlayerOnboardingFormPage(String(req.query.deviceId || playerDeviceId || '').trim()));
const deviceId = playerDeviceId;
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
const clientId = normalizeDeviceId(req.query.clientId);
const pairingSession = getPairingSession(deviceId, clientId);
const pairingParams = [];
if (pairingSession && pairingSession.code) {
pairingParams.push(`code=${encodeURIComponent(pairingSession.code)}`);
}
const pairingQuery = pairingParams.length ? `?${pairingParams.join('&')}` : '';
if (bridgeBaseUrl && pairingSession && pairingSession.code) {
const response = await fetchThinClient(req, `/api/onboarding/url?pairingCode=${encodeURIComponent(pairingSession.code)}`);
const payload = await readJsonResponse(response);
if (payload && payload.url) {
return res.redirect(String(payload.url));
}
}
return res.redirect(`${onboardingBaseUrl}/pairing${pairingQuery}`);
} catch (error) {
next(error);
}
@@ -315,7 +427,7 @@ function registerPlayerOnboardingRoutes(app, options) {
next();
}, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const deviceId = normalizeDeviceId(req.query.deviceId || req.query.clientId);
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(deviceId || ''), {
method: 'GET'
@@ -347,6 +459,26 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.get('/api/onboarding/resolve', requireOnboardingAuth, function (req, res) {
const pairingCode = String(req.query.pairingCode || '').trim();
const pairing = findPairingSession(pairingCode);
if (!pairing) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
res.json({ deviceId: pairing.deviceId, clientId: pairing.session.clientId || null });
});
app.get('/api/onboarding/session', requireOnboardingPageAuth, function (req, res) {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const clientId = normalizeDeviceId(req.query.clientId);
const pairingSession = getPairingSession(deviceId, clientId);
if (!pairingSession) {
return res.status(503).json({ error: 'Player identity is unavailable.' });
}
res.set('Cache-Control', 'no-store');
res.json({ deviceId: deviceId, pairingCode: pairingSession.code });
});
app.get('/api/onboarding/screens', requireOnboardingPageAuth, async function (_req, res, next) {
try {
if (bridgeBaseUrl) {
@@ -366,14 +498,40 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.get('/api/onboarding/qr', async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.query.deviceId) || playerDeviceId;
const clientId = normalizeDeviceId(req.query.clientId);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
}
const onboardingUrl = `${getPublicBaseUrl(req, playerPublicUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
const pairingSession = getPairingSession(deviceId, clientId);
if (!pairingSession) {
return res.status(503).json({ error: 'Pairing session is unavailable.' });
}
if (bridgeBaseUrl) {
const response = await fetchThinClient(req, `/api/onboarding/qr?pairingCode=${encodeURIComponent(pairingSession.code)}`);
if (response && response.ok) {
const svg = await response.text();
res.set('Content-Type', response.headers.get('content-type') || 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
return res.send(svg);
}
}
const onboardingBaseUrl = String(process.env.WEB_PUBLIC_URL || '').trim().replace(/\/$/, '') || getPublicBaseUrl(req, playerPublicUrl);
const onboardingUrl = `${onboardingBaseUrl}/pairing?code=${encodeURIComponent(pairingSession.code)}`;
const svg = await createStyledQrCodeSvg({
value: onboardingUrl,
qr_margin: 20,
qr_dots_type: 'dots',
qr_dots_color: '#f4f8f5',
qr_corners_square_type: 'dot',
qr_corners_square_color: '#f4f8f5',
qr_corners_dot_type: 'dot',
qr_corners_dot_color: '#f0bd70',
qr_background_transparent: true
});
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
res.set('Cache-Control', 'no-store');
res.send(svg);
@@ -382,20 +540,28 @@ function registerPlayerOnboardingRoutes(app, options) {
}
});
app.post('/api/onboarding', requireOnboardingPageAuth, express.json(), async function (req, res, next) {
app.post('/api/onboarding', express.json(), requireOnboardingAuth, async function (req, res, next) {
try {
const deviceId = normalizeDeviceId(req.body && req.body.deviceId) || playerDeviceId;
const deviceId = playerDeviceId;
const clientName = String((req.body && req.body.clientName) || '').trim();
const screenSlug = String((req.body && req.body.screenSlug) || '').trim();
const pairingCode = String((req.body && req.body.pairingCode) || '').trim();
const clientId = normalizeDeviceId(req.body && req.body.clientId);
const retryAfterSeconds = isOnboardingSignupRateLimited(req, deviceId);
if (retryAfterSeconds) {
res.set('Retry-After', String(retryAfterSeconds));
return res.status(429).json({ error: 'Too many onboarding attempts. Please try again later.' });
}
const pairing = findPairingSession(pairingCode);
const pairingSession = pairing && pairing.session;
if (!isValidOnboardingPairingCode(pairingSession, pairingCode)) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
if (bridgeBaseUrl) {
const forwardedBody = Object.assign({}, req.body || {}, {
deviceId: deviceId
clientId: clientId || null
});
const response = await fetch(new URL('/api/onboarding', bridgeBaseUrl).toString(), {
method: 'POST',
@@ -413,6 +579,9 @@ function registerPlayerOnboardingRoutes(app, options) {
if (!payload) {
return res.status(502).json({ error: 'Player bridge returned an invalid response.' });
}
if (response.ok) {
pairingSessions.delete(deviceId);
}
payload.playerUrl = payload && payload.screenSlug ? `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(payload.screenSlug)}` : `${getPublicBaseUrl(req, playerPublicUrl)}/screen/${encodeURIComponent(screenSlug)}`;
return res.json(payload);
}
@@ -426,8 +595,11 @@ function registerPlayerOnboardingRoutes(app, options) {
return res.status(400).json({ error: 'Screen is required' });
}
const status = await bindDeviceToScreen(pool, deviceId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
await bindPlayerToScreen(pool, deviceId, screenSlug);
if (!clientId) {
return res.status(400).json({ error: 'Client ID is required' });
}
const status = await bindDeviceToScreen(pool, clientId, clientName, screenSlug, playerRuntime.isClientNameAvailableOnScreen, playerRuntime, onboardingStore);
pairingSessions.delete(deviceId);
res.json({
deviceId: deviceId,
clientName: status ? status.client_name : clientName,
@@ -456,5 +628,6 @@ module.exports = {
upsertPlayerRegistration: upsertPlayerRegistration,
bindPlayerToScreen: bindPlayerToScreen,
bindDeviceToScreen: bindDeviceToScreen,
isValidOnboardingPairingCode: isValidOnboardingPairingCode,
registerPlayerOnboardingRoutes: registerPlayerOnboardingRoutes
};
@@ -65,10 +65,11 @@
var formData = new FormData(form);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
var pairingCode = String(formData.get("pairingCode") || "").trim();
if (!clientName) { setMessage("Client name is required."); return; }
if (!screenSlug) { setMessage("Screen is required."); return; }
setMessage("Saving client...");
var payload = { clientName: clientName, screenSlug: screenSlug };
var payload = { clientName: clientName, screenSlug: screenSlug, pairingCode: pairingCode };
if (deviceId) {
payload.deviceId = deviceId;
}
@@ -13,23 +13,12 @@
}
var screenKey = "pulse-signage-player-screen-slug";
var qr = document.getElementById("onboarding-qr");
var status = document.getElementById("onboarding-status");
var localForm = document.getElementById("onboarding-local-form");
var localMessage = document.getElementById("onboarding-message");
var localScreenSelect = document.getElementById("onboarding-screen-select");
var pairingCodeElement = document.getElementById("onboarding-pairing-code");
var qrPlaceholderSrc = "data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E";
function parseResponseError(response) {
return response.text().then(function (text) {
var fallbackMessage = text && text.trim() ? text.trim() : "Unable to save onboarding.";
try {
var payload = JSON.parse(text);
return payload && payload.error ? payload.error : fallbackMessage;
} catch (_error) {
return fallbackMessage;
}
});
}
function getDeviceId() {
var configuredDeviceId = document.getElementById("onboarding-shell");
configuredDeviceId = configuredDeviceId ? String(configuredDeviceId.getAttribute("data-player-device-id") || "").trim() : "";
if (configuredDeviceId) { return configuredDeviceId; }
var stored = "";
try { stored = window.sessionStorage.getItem(deviceKey) || ""; } catch (_error) { stored = ""; }
if (stored) { return stored; }
@@ -37,115 +26,57 @@
try { window.sessionStorage.setItem(deviceKey, next); } catch (_error2) {}
return next;
}
function setStatus(message) { if (status) { status.textContent = message; } }
function setLocalMessage(message) { if (localMessage) { localMessage.textContent = message || ""; } }
function setSelectOptions(select, screens, selectedSlug) {
if (!select) { return; }
while (select.firstChild) { select.removeChild(select.firstChild); }
var placeholder = document.createElement("option");
placeholder.value = "";
placeholder.textContent = "Select a screen";
select.appendChild(placeholder);
(Array.isArray(screens) ? screens : []).forEach(function (screen) {
var option = document.createElement("option");
option.value = String(screen && screen.slug ? screen.slug : "");
option.textContent = String(screen && (screen.name || screen.slug) ? (screen.name || screen.slug) : "Screen");
if (selectedSlug && String(option.value) === String(selectedSlug)) {
option.selected = true;
}
select.appendChild(option);
});
}
function loadScreens(selectedSlug) {
return fetch("/api/onboarding/screens", { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
var screens = payload && Array.isArray(payload.screens) ? payload.screens : [];
setSelectOptions(localScreenSelect, screens, selectedSlug);
return screens;
})
.catch(function () { setSelectOptions(localScreenSelect, [], selectedSlug); return []; });
}
function loadQr(deviceId) {
function loadQr(deviceId, clientId) {
if (!qr) { return; }
qr.onerror = function () {
qr.src = qrPlaceholderSrc;
};
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId);
qr.src = "/api/onboarding/qr?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId);
}
function submitOnboarding(deviceId, clientName, screenSlug) {
return fetch("/api/onboarding", {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ deviceId: deviceId, clientName: clientName, screenSlug: screenSlug })
})
.then(function (response) {
if (response.ok) {
return response.json();
}
return parseResponseError(response).then(function (messageText) {
throw new Error(messageText);
});
})
function loadPairingSession(deviceId, clientId) {
return fetch("/api/onboarding/session?deviceId=" + encodeURIComponent(deviceId) + "&clientId=" + encodeURIComponent(clientId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (!payload || !payload.screenSlug) { throw new Error("Unable to save onboarding."); }
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
setLocalMessage("Onboarding complete.");
if (localForm) {
Array.prototype.slice.call(localForm.querySelectorAll("input, select, button")).forEach(function (control) {
control.disabled = true;
});
if (payload && payload.pairingCode && pairingCodeElement) {
pairingCodeElement.textContent = payload.pairingCode;
}
});
return payload;
})
.catch(function () { return null; });
}
function getClientId() {
var stored = getSessionStorageItem("pulse-signage-player-client-id");
if (stored) { return stored; }
var next = (window.crypto && window.crypto.randomUUID ? window.crypto.randomUUID() : "client-" + Date.now() + "-" + Math.random().toString(16).slice(2));
setSessionStorageItem("pulse-signage-player-client-id", next);
return next;
}
function redirectIfOnboarded(deviceId) {
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(deviceId), { cache: "no-store" })
var bindingId = getClientId() || deviceId;
return fetch("/api/onboarding/status?deviceId=" + encodeURIComponent(bindingId), { cache: "no-store" })
.then(function (response) { return response.ok ? response.json() : null; })
.then(function (payload) {
if (payload && payload.onboarded && payload.screenSlug) {
if (payload.clientName) { setSessionStorageItem(clientNameKey, payload.clientName); }
if (payload.clientName && payload.screenSlug) { setSessionStorageItem(getClientNameStorageKey(payload.screenSlug), payload.clientName); }
try { window.localStorage.setItem(screenKey, payload.screenSlug); } catch (_error) {}
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug));
window.location.replace("/screen/" + encodeURIComponent(payload.screenSlug) + "?clientId=" + encodeURIComponent(clientId));
return true;
}
return false;
})
.catch(function () { return false; });
}
var clientId = getClientId();
var deviceId = getDeviceId();
if (localForm) {
localForm.addEventListener("submit", function (event) {
event.preventDefault();
var formData = new FormData(localForm);
var clientName = String(formData.get("clientName") || "").trim();
var screenSlug = String(formData.get("screenSlug") || "").trim();
if (!clientName) { setLocalMessage("Client name is required."); return; }
if (!screenSlug) { setLocalMessage("Screen is required."); return; }
setLocalMessage("Saving client...");
submitOnboarding(deviceId, clientName, screenSlug).catch(function (error) {
setLocalMessage(error && error.message ? error.message : "Unable to save onboarding.");
});
});
}
loadScreens().then(function () {
try {
var storedClientName = getSessionStorageItem(clientNameKey) || "";
if (storedClientName && localForm) {
var clientNameInput = localForm.querySelector("input[name=\"clientName\"]");
if (clientNameInput) { clientNameInput.value = storedClientName; }
}
} catch (_error) {}
});
redirectIfOnboarded(deviceId).then(function (redirected) {
if (redirected) { return; }
if (qr && !qr.getAttribute("src")) {
qr.src = qrPlaceholderSrc;
}
loadQr(deviceId);
setStatus("Waiting for onboarding to finish.");
loadPairingSession(deviceId, clientId).then(function () {
loadQr(deviceId, clientId);
});
window.setInterval(function () { redirectIfOnboarded(deviceId); }, 2000);
});
}());
+1 -1
View File
@@ -90,7 +90,7 @@
});
}).then(function (payload) {
var serverName = payload && payload.clientName ? String(payload.clientName).trim() : '';
if (serverName) {
if (serverName && !getOnboardingClientName()) {
applyOnboardingClientName(serverName, null);
}
return onboardingClientName || getOnboardingClientName();
+3
View File
@@ -131,6 +131,9 @@
if (window.__pulsePageAuthToken) {
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
}
if (typeof getCommandClientId === 'function') {
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
}
if (announcementEtag) {
request.setRequestHeader('If-None-Match', announcementEtag);
}
+2 -6
View File
@@ -1,6 +1,6 @@
<script>
const slug = {{SLUG_JSON}};
const initialData = {{INITIAL_DATA_JSON}};
let initialData = {{INITIAL_DATA_JSON}};
window.slug = slug;
window.initialData = initialData;
const app = document.getElementById('app');
@@ -73,7 +73,7 @@
let slideExpiresAt = null;
const slideFadeDurationMs = 560;
const commandSocketPath = '/ws/screens/' + encodeURIComponent(slug);
const commandClientStorageKey = 'pulse-signage-player-client-id:' + slug;
const commandClientStorageKey = 'pulse-signage-player-client-id';
const playlistSnapshotStorageKey = 'pulse-signage-player-playlist-snapshot:' + slug;
const initialPlaylistSnapshot = loadPlaylistSnapshot();
let offlineBanner = null;
@@ -181,10 +181,6 @@
releaseScreenWakeLock();
});
if (initialPlaylistSnapshot && initialPlaylistSnapshot.slides.length) {
applyPlaylistSnapshot(initialPlaylistSnapshot);
}
if (slides.length) {
if (!currentPlaylistSignature) {
currentPlaylistSignature = getPlaylistRevision(initialData && initialData.slides ? initialData : { slides: slides });
+3 -1
View File
@@ -223,7 +223,7 @@ function createPlayerPlaylistService(options) {
let regionRows = [];
if (templateIds.length) {
[templateRows] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.background_gradient, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM c_templates st
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
@@ -422,6 +422,8 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, template.canvas_size_height);
updatePlaylistRevisionHash(hash, template.background_image_path);
updatePlaylistRevisionHash(hash, template.background_color);
updatePlaylistRevisionHash(hash, template.background_gradient);
updatePlaylistRevisionHash(hash, template.background_gradient);
updatePlaylistRevisionHash(hash, template.modified_at);
});
+213 -20
View File
@@ -11,9 +11,9 @@ body {
body.onboarding-page {
background:
radial-gradient(circle at top, rgba(82, 144, 255, 0.28), transparent 32%),
radial-gradient(circle at bottom right, rgba(34, 197, 94, 0.18), transparent 26%),
linear-gradient(160deg, #09111f 0%, #0b1323 52%, #111827 100%);
radial-gradient(circle at 78% 20%, rgba(55, 195, 178, 0.16), transparent 28%),
radial-gradient(circle at 12% 90%, rgba(237, 177, 89, 0.12), transparent 30%),
linear-gradient(145deg, #07131b 0%, #0b2028 58%, #10252a 100%);
overflow-x: hidden;
overflow-y: auto;
}
@@ -48,14 +48,43 @@ body.thumbnail-preview .player-offline-banner {
}
.onboarding-shell {
min-height: 100vh;
min-height: 100%;
display: flex;
align-items: center;
justify-content: center;
padding: clamp(16px, 3vw, 40px);
padding: clamp(24px, 5vw, 72px);
box-sizing: border-box;
}
.onboarding-stage {
width: min(100%, 1160px);
}
.onboarding-header {
display: flex;
align-items: center;
gap: 14px;
margin-bottom: 10px;
}
.onboarding-brand-mark {
width: 42px;
height: 42px;
display: grid;
place-items: center;
border-radius: 13px;
background: #f0bd70;
color: #10252a;
font-size: 1.45rem;
font-weight: 800;
}
.onboarding-label {
margin: 3px 0 0;
color: #8faeb0;
font-size: 0.88rem;
}
.onboarding-card {
width: min(100%, 1040px);
padding: clamp(20px, 3vw, 40px);
@@ -73,14 +102,35 @@ body.thumbnail-preview .player-offline-banner {
line-height: 1.05;
}
.onboarding-stage h1 {
max-width: 560px;
font-size: clamp(2.5rem, 5vw, 5rem);
letter-spacing: 0;
}
.onboarding-stage--landing h1 {
max-width: 500px;
font-size: clamp(2.1rem, 3.6vw, 3.4rem);
line-height: 1.08;
}
.onboarding-kicker {
margin: 0 0 12px;
text-transform: uppercase;
letter-spacing: 0.14em;
color: #8ab4ff;
color: #f0bd70;
font-size: 0.82rem;
}
.onboarding-step {
margin: 0 0 18px;
color: #70d0c2;
font-size: 0.9rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.onboarding-copy {
margin: 0 0 28px;
color: #cbd5e1;
@@ -90,36 +140,123 @@ body.thumbnail-preview .player-offline-banner {
.onboarding-layout {
display: grid;
grid-template-columns: minmax(280px, 1fr) minmax(320px, 1fr);
grid-template-columns: minmax(320px, 0.9fr) minmax(320px, 1.1fr);
gap: clamp(20px, 3vw, 32px);
align-items: stretch;
}
.onboarding-copy-panel {
display: flex;
grid-column: 1;
grid-row: 1;
flex-direction: column;
justify-content: center;
}
.onboarding-instructions {
display: grid;
gap: 14px;
max-width: 440px;
margin: 18px 0 0;
padding: 0;
list-style: none;
counter-reset: setup-step;
}
.onboarding-instructions li {
display: grid;
grid-template-columns: 24px 1fr;
gap: 10px;
color: #b7cccd;
font-size: 0.96rem;
line-height: 1.35;
counter-increment: setup-step;
}
.onboarding-instructions li::before {
content: counter(setup-step);
width: 22px;
height: 22px;
display: grid;
place-items: center;
border: 1px solid rgba(112, 208, 194, 0.55);
border-radius: 50%;
color: #70d0c2;
font-size: 0.75rem;
font-weight: 700;
}
.onboarding-pin-block {
margin-top: 28px;
}
.onboarding-pin-label {
display: block;
margin-bottom: 10px;
color: #8faeb0;
font-size: 0.92rem;
}
.onboarding-pin-label strong { color: #f0bd70; }
.onboarding-pin {
display: block;
color: #f4f8f5;
font-size: clamp(1.7rem, 3vw, 2.8rem);
font-weight: 800;
letter-spacing: 0.18em;
line-height: 1;
}
.onboarding-qr-pane {
display: grid;
gap: 16px;
width: min(100%, 420px);
grid-column: 2;
grid-row: 1;
gap: 0;
align-content: start;
justify-self: center;
}
.onboarding-qr-frame {
display: flex;
width: min(100%, 420px);
aspect-ratio: 1;
box-sizing: border-box;
justify-content: center;
padding: 22px;
border-radius: 26px;
background: rgba(255, 255, 255, 0.04);
border: 1px solid rgba(255, 255, 255, 0.08);
align-items: center;
justify-self: center;
padding: 14px;
border-radius: 22px;
background: rgba(7, 19, 27, 0.7);
border: 1px solid rgba(244, 248, 245, 0.42);
box-shadow: 0 24px 64px rgba(0, 0, 0, 0.22);
}
.onboarding-qr-frame img {
width: min(100%, 320px);
width: min(100%, 390px);
aspect-ratio: 1;
display: block;
background: #fff;
border-radius: 18px;
padding: 16px;
background: transparent;
border-radius: 16px;
padding: 12px;
box-sizing: border-box;
}
.onboarding-qr-caption {
margin: 6px 0 0;
color: #8faeb0;
font-size: 0.92rem;
text-align: center;
}
.onboarding-brand-title {
margin-bottom: 10px;
font-size: 1.2rem;
font-weight: 700;
letter-spacing: 0.1em;
}
.onboarding-form {
display: grid;
gap: 14px;
@@ -181,14 +318,14 @@ body.thumbnail-preview .player-offline-banner {
}
.onboarding-status {
margin-top: 8px;
margin-top: clamp(32px, 6vh, 56px);
min-height: 1.4em;
color: #cbd5e1;
color: #8faeb0;
font-size: 0.96rem;
}
.onboarding-card--landing .onboarding-status {
text-align: center;
text-align: left;
}
@media (max-width: 860px), (orientation: portrait) {
@@ -200,12 +337,45 @@ body.thumbnail-preview .player-offline-banner {
grid-template-columns: 1fr;
}
.onboarding-copy-panel,
.onboarding-qr-pane {
grid-column: 1;
}
.onboarding-copy-panel {
grid-row: 1;
}
.onboarding-qr-pane {
grid-row: 2;
}
.onboarding-card {
width: 100%;
}
.onboarding-qr-frame img {
width: min(100%, 280px);
width: min(100%, 390px);
}
.onboarding-qr-frame {
width: min(100%, 420px);
}
.onboarding-qr-pane {
width: min(100%, 420px);
}
.onboarding-stage--landing h1 {
font-size: clamp(2rem, 7vw, 3rem);
}
.onboarding-header {
margin-bottom: 38px;
}
.onboarding-pin {
font-size: clamp(1.7rem, 9vw, 2.8rem);
}
}
@@ -239,6 +409,29 @@ body.screen-blackout #app {
opacity: 0;
}
.player-keyboard-feedback {
position: fixed;
top: 1.5rem;
left: 50%;
z-index: 10000;
padding: 0.65rem 1rem;
border: 1px solid rgba(255, 255, 255, 0.32);
border-radius: 0.4rem;
background: rgba(15, 23, 42, 0.88);
color: #fff;
font-size: 0.95rem;
font-weight: 700;
opacity: 0;
pointer-events: none;
transform: translate(-50%, -0.5rem);
transition: opacity 120ms ease, transform 120ms ease;
}
.player-keyboard-feedback.is-visible {
opacity: 1;
transform: translate(-50%, 0);
}
.player-announcement-layer {
position: fixed;
left: 50%;
+71 -2
View File
@@ -440,6 +440,34 @@ function isEditableTarget(target) {
return ['INPUT', 'TEXTAREA', 'SELECT', 'OPTION'].indexOf(tagName) !== -1;
}
var keyboardFeedbackTimer = null;
function showKeyboardFeedback(message) {
if (typeof document === 'undefined' || !document.body) {
return;
}
var feedback = document.querySelector('.player-keyboard-feedback');
if (!feedback) {
feedback = document.createElement('div');
feedback.className = 'player-keyboard-feedback';
feedback.setAttribute('aria-live', 'polite');
document.body.appendChild(feedback);
}
feedback.textContent = String(message || '');
feedback.classList.remove('is-visible');
void feedback.offsetWidth;
feedback.classList.add('is-visible');
if (keyboardFeedbackTimer) {
window.clearTimeout(keyboardFeedbackTimer);
}
keyboardFeedbackTimer = window.setTimeout(function () {
feedback.classList.remove('is-visible');
keyboardFeedbackTimer = null;
}, 900);
}
function handlePlayerKeydown(event) {
if (!event || event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey) {
return;
@@ -452,12 +480,28 @@ function handlePlayerKeydown(event) {
if (event.key === 'ArrowLeft') {
event.preventDefault();
navigateSlides(-1);
showKeyboardFeedback('Previous slide');
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
navigateSlides(1);
showKeyboardFeedback('Next slide');
return;
}
if (String(event.key || '').toLowerCase() === 'p') {
event.preventDefault();
setPaused(!isPaused);
showKeyboardFeedback(isPaused ? 'Paused' : 'Playing');
return;
}
if (String(event.key || '').toLowerCase() === 'b') {
event.preventDefault();
setBlackout(!isBlackout);
showKeyboardFeedback(isBlackout ? 'Blackout on' : 'Blackout off');
}
}
@@ -489,6 +533,10 @@ function handleCommandMessage(rawMessage) {
} catch (_error) {
return;
}
if (payload && payload.type === 'client-id-conflict') {
handleClientIdConflict();
return;
}
if (!payload || payload.type !== 'command') {
return;
@@ -505,7 +553,17 @@ function handleCommandMessage(rawMessage) {
return;
case 'redirect':
if (payload.url) {
window.location.replace(String(payload.url));
var redirectUrl = String(payload.url);
var authorizeMove = payload.moveToken
? fetch('/api/screen-move-authorize', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Accept': 'application/json' },
body: JSON.stringify({ moveToken: String(payload.moveToken) })
})
: Promise.resolve();
authorizeMove.finally(function () {
window.location.replace(redirectUrl);
});
}
return;
case 'pause':
@@ -541,6 +599,13 @@ function handleCommandMessage(rawMessage) {
}
}
function handleClientIdConflict() {
var replacementClientId = regenerateCommandClientId();
var onboardingUrl = new URL('/', window.location.origin);
onboardingUrl.searchParams.set('clientId', replacementClientId);
window.location.replace(onboardingUrl.toString());
}
// Retry the command websocket after a disconnect.
function scheduleCommandReconnect() {
if (commandReconnectTimer) {
@@ -577,8 +642,12 @@ function connectCommandSocket() {
handleCommandMessage(event.data);
};
socket.onclose = function () {
socket.onclose = function (event) {
commandSocket = null;
if (event && event.code === 4009) {
handleClientIdConflict();
return;
}
scheduleCommandReconnect();
};
+21 -9
View File
@@ -156,6 +156,9 @@ function refresh() {
if (window.__pulsePageAuthToken) {
request.setRequestHeader('x-pulse-page-auth', window.__pulsePageAuthToken);
}
if (typeof getCommandClientId === 'function') {
request.setRequestHeader('x-pulse-client-id', getCommandClientId());
}
if (currentPlaylistEtag) {
request.setRequestHeader('If-None-Match', currentPlaylistEtag);
}
@@ -172,6 +175,10 @@ function refresh() {
return;
}
if (request.status < 200 || request.status >= 300) {
if (request.status === 401 || request.status === 403) {
window.location.replace('/');
return;
}
setOfflineBannerVisible(true);
scheduleRefreshRetry();
logDebug(
@@ -189,16 +196,21 @@ function refresh() {
const nextActiveSlides = getActiveSlidesFrom(nextSlides);
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
if (window.initialData && typeof window.initialData === 'object') {
window.initialData.screen = data.screen || window.initialData.screen || null;
window.initialData.playlist = data.playlist || null;
window.initialData.slides = nextSlides;
window.initialData.rssFeeds = Array.isArray(data.rssFeeds) ? data.rssFeeds : [];
window.initialData.apiSources = Array.isArray(data.apiSources) ? data.apiSources : [];
window.initialData.timetableGroups = Array.isArray(data.timetableGroups) ? data.timetableGroups : [];
window.initialData.weatherLocations = Array.isArray(data.weatherLocations) ? data.weatherLocations : [];
window.initialData.revision = nextSignature;
var refreshedInitialData = Object.assign({},
typeof initialData !== 'undefined' && initialData ? initialData : (window.initialData || {}), {
screen: data.screen || null,
playlist: data.playlist || null,
slides: nextSlides,
rssFeeds: Array.isArray(data.rssFeeds) ? data.rssFeeds : [],
apiSources: Array.isArray(data.apiSources) ? data.apiSources : [],
timetableGroups: Array.isArray(data.timetableGroups) ? data.timetableGroups : [],
weatherLocations: Array.isArray(data.weatherLocations) ? data.weatherLocations : [],
revision: nextSignature
});
if (typeof initialData !== 'undefined') {
initialData = refreshedInitialData;
}
window.initialData = refreshedInitialData;
savePlaylistSnapshot({
slides: nextSlides,
signature: nextSignature,
@@ -211,6 +211,16 @@ function getCommandClientId() {
return commandClientId;
}
function regenerateCommandClientId() {
commandClientId = null;
try {
window.sessionStorage.removeItem(commandClientStorageKey);
} catch (_error) {
// ignore storage errors
}
return getCommandClientId();
}
// Load the most recent playlist snapshot from browser storage.
function loadPlaylistSnapshot() {
try {
+20 -5
View File
@@ -782,6 +782,7 @@ function getTemplateLayout(template) {
canvasHeight: canvasSize.height,
background: template.background_image_path ? '<img class="template-background" src="' + escapeHtml(template.background_image_path) + '" alt="" />' : '',
backgroundColor: template.background_color || '#111111',
backgroundGradient: template.background_gradient || '',
regions: regions
};
@@ -790,17 +791,31 @@ function getTemplateLayout(template) {
}
// Build a dark backdrop style for template and media canvases.
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
function buildBackdropStyle(backgroundColor, backgroundImagePath, backgroundGradient) {
var color = String(backgroundColor || '#111111').trim() || '#111111';
var style = 'background-color:' + escapeHtml(color) + ';';
var gradient = '';
try {
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (stops.length >= 2) {
var angle = Number(gradientData.angle);
gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
}
}
} catch (_error) {
gradient = '';
}
if (backgroundImagePath) {
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '");';
style += 'background-position:center;background-size:contain;background-repeat:no-repeat;';
style += 'background-image:url("' + escapeHtml(backgroundImagePath) + '")' + (gradient ? ',' + gradient : '') + ';';
style += 'background-position:center,center;background-size:contain,cover;background-repeat:no-repeat,no-repeat;';
return style;
}
style += 'background-image:none;background-position:center;background-size:cover;background-repeat:no-repeat;';
style += 'background-image:' + (gradient || 'none') + ';background-position:center;background-size:cover;background-repeat:no-repeat;';
return style;
}
@@ -876,7 +891,7 @@ function renderTemplateSlideMarkup(slide) {
animationConfig: normalizePlayerAnimationConfig(region.animationJson)
});
}).join('') : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path, layout.backgroundGradient) : '';
if (layout) {
setPlayerCanvasDimensions(layout.canvasWidth, layout.canvasHeight);
}
+1 -1
View File
@@ -427,7 +427,7 @@ function getPlayerAnnouncementTemplatesScript() {
function getAnnouncementIconsDataScript() {
return [
'(function () {',
' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_KEYS) + ';',
' window.pulseAnnouncementIconKeys = ' + safeJsonForScript(announcementIcons.ANNOUNCEMENT_ICON_CATALOG_KEYS) + ';',
' window.pulseAnnouncementDefaultIconKey = ' + safeJsonForScript(announcementIcons.DEFAULT_ANNOUNCEMENT_ICON) + ';',
'}());'
].join('\n');
+24 -23
View File
@@ -36,34 +36,31 @@ function getPlayerServiceWorkerRegistrationScript() {
].join('');
}
function renderOnboardingLandingBody() {
function renderOnboardingLandingBody(options) {
const onboardingCode = String(options && options.pairingCode || '').trim();
const deviceId = String(options && options.deviceId || '').trim();
return [
'<main class="onboarding-shell">',
' <section class="onboarding-card onboarding-card--landing">',
' <p class="onboarding-kicker">Pulse Signage</p>',
' <h1>Onboard this player</h1>',
' <p class="onboarding-copy">Choose an existing screen, name the client, and either scan the QR code or finish right here with a keyboard and mouse.</p>',
' <main id="onboarding-shell" class="onboarding-shell">',
' <section class="onboarding-stage onboarding-stage--landing">',
' <div class="onboarding-layout">',
' <div class="onboarding-qr-pane">',
' <div class="onboarding-qr-frame">',
' <img id="onboarding-qr" alt="Onboarding QR code" src="data:image/svg+xml;charset=utf-8,%3Csvg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 320 320%22%3E%3Crect width=%22320%22 height=%22320%22 rx=%2224%22 fill=%22%23ffffff%22/%3E%3Crect x=%2230%22 y=%2230%22 width=%22260%22 height=%22260%22 rx=%2218%22 fill=%22%23f8fafc%22 stroke=%22%23cbd5e1%22 stroke-width=%223%22 stroke-dasharray=%2212 10%22/%3E%3Cpath d=%22M106 118h108M106 156h108M106 194h72%22 stroke=%22%2394a3b8%22 stroke-width=%2214%22 stroke-linecap=%22round%22/%3E%3Ccircle cx=%22128%22 cy=%22248%22 r=%2212%22 fill=%22%2394a3b8%22/%3E%3Ctext x=%22160%22 y=%2278%22 text-anchor=%22middle%22 fill=%22%230f172a%22 font-family=%22Arial,sans-serif%22 font-size=%2224%22 font-weight=%22700%22%3EQR code loading%3C/text%3E%3Ctext x=%22160%22 y=%22266%22 text-anchor=%22middle%22 fill=%22%234b5563%22 font-family=%22Arial,sans-serif%22 font-size=%2214%22%3EPlease wait%3C/text%3E%3C/svg%3E" />',
' </div>',
' <div id="onboarding-status" class="onboarding-status">Preparing onboarding link...</div>',
' <p class="onboarding-qr-caption">Scan this QR code with your phone</p>',
' </div>',
' <div class="onboarding-copy-panel">',
' <p class="onboarding-kicker onboarding-brand-title">Pulse Signage</p>',
' <h1>Quickly set up with your phone</h1>',
' <ol class="onboarding-instructions">',
' <li>Open the camera and scan the QR code.</li>',
' <li>Log in to Pulse Signage and choose a screen.</li>',
' </ol>',
' <div class="onboarding-pin-block">',
' <span class="onboarding-pin-label">Pairing Code</span>',
' <strong id="onboarding-pairing-code" class="onboarding-pin">' + Handlebars.escapeExpression(onboardingCode || 'Loading...') + '</strong>',
' </div>',
' </div>',
' <form id="onboarding-local-form" class="onboarding-form onboarding-form--local">',
' <label>',
' <span>Client name</span>',
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" autocomplete="off" />',
' </label>',
' <label>',
' <span>Screen</span>',
' <select name="screenSlug" id="onboarding-screen-select" required>',
' <option value="">Loading screens...</option>',
' </select>',
' </label>',
' <button type="submit">Save client</button>',
' <div id="onboarding-message" class="onboarding-status"></div>',
' </form>',
' </div>',
' </section>',
'</main>'
@@ -79,6 +76,10 @@ function renderOnboardingFormBody(deviceId) {
' <p class="onboarding-copy">Pick an existing screen and give this player a friendly name that will persist after refreshes.</p>',
' <form id="onboarding-form" class="onboarding-form">',
' <label>',
' <span>Pairing code</span>',
' <input name="pairingCode" type="text" inputmode="numeric" pattern="[0-9]{6}" maxlength="6" required autocomplete="one-time-code" placeholder="Enter the code shown on the kiosk" />',
' </label>',
' <label>',
' <span>Client name</span>',
' <input name="clientName" type="text" maxlength="255" required placeholder="Lobby player" />',
' </label>',
@@ -136,13 +137,13 @@ function renderPlayerPage(slug, initialData) {
});
}
function renderPlayerOnboardingLandingPage() {
function renderPlayerOnboardingLandingPage(options) {
const pageAuthToken = createPageAuthBundle({ scope: 'onboarding' });
const fontStylesheetHref = getFontStylesheetHref(PLAYER_MEDIA_DIR);
return renderPage(getPlayerPageTemplate(), {
title: 'Onboard player',
bodyClass: 'onboarding-page',
body: renderOnboardingLandingBody(),
body: renderOnboardingLandingBody(options),
stylesheets: fontStylesheetHref ? [fontStylesheetHref] : [],
script: createPageFetchAuthScript(pageAuthToken) + getPlayerServiceWorkerRegistrationScript() + renderOnboardingLandingScript()
});
+87 -14
View File
@@ -62,11 +62,15 @@ function registerPlayerRoutes(app, options) {
body: body
}));
if (req.headers['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(req.headers['x-pulse-page-auth']).trim();
const requestHeaders = req && req.headers ? req.headers : {};
if (requestHeaders['x-pulse-page-auth']) {
headers['x-pulse-page-auth'] = String(requestHeaders['x-pulse-page-auth']).trim();
}
if (req.headers['if-none-match']) {
headers['if-none-match'] = String(req.headers['if-none-match']).trim();
if (requestHeaders['if-none-match']) {
headers['if-none-match'] = String(requestHeaders['if-none-match']).trim();
}
if (requestHeaders['x-pulse-client-id']) {
headers['x-pulse-client-id'] = String(requestHeaders['x-pulse-client-id']).trim();
}
if (requestOptions.contentType) {
headers['content-type'] = requestOptions.contentType;
@@ -96,6 +100,76 @@ function registerPlayerRoutes(app, options) {
}
}
async function getBoundScreenSlug(req) {
if (!playerDeviceId) {
return null;
}
const clientId = String(req.query && req.query.clientId || '').trim();
if (!clientId) {
return null;
}
const bindingId = clientId;
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/onboarding/status?deviceId=' + encodeURIComponent(bindingId), {
method: 'GET'
});
const status = await readJsonResponse(response);
return status && status.screenSlug ? String(status.screenSlug).trim() : null;
}
const [rows] = await pool.query(
`SELECT s.slug
FROM d_onboarding_devices d
JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?`,
[bindingId]
);
return rows[0] && rows[0].slug ? String(rows[0].slug).trim() : null;
}
async function isClientAuthorizedForScreen(req, requestedSlug) {
const clientId = String(req.headers['x-pulse-client-id'] || '').trim();
if (!clientId) {
return false;
}
const boundSlug = await getBoundScreenSlug({ query: { clientId: clientId } });
return boundSlug === String(requestedSlug || '').trim();
}
function isAuthorizedScreenMove(req, requestedSlug) {
const queryToken = String(req.query && req.query.moveToken || '').trim();
const cookieHeader = String(req.headers && req.headers.cookie || '');
const cookieToken = cookieHeader.split(';').map(function (part) {
const separator = part.indexOf('=');
return separator === -1 ? null : [part.slice(0, separator).trim(), part.slice(separator + 1).trim()];
}).filter(Boolean).find(function (entry) { return entry[0] === 'pulse-screen-move'; });
const token = queryToken || (cookieToken ? decodeURIComponent(cookieToken[1]) : '');
if (!token) {
return false;
}
const payload = verifyPageAuthToken(token);
return Boolean(payload
&& String(payload.scope || '').trim() === 'screen-move'
&& String(payload.playerId || '').trim() === String(playerDeviceId || '').trim()
&& String(payload.screenSlug || '').trim() === String(requestedSlug || '').trim());
}
app.post('/api/screen-move-authorize', express.json(), function (req, res) {
const token = String(req.body && req.body.moveToken || '').trim();
const payload = verifyPageAuthToken(token);
if (!payload
|| String(payload.scope || '').trim() !== 'screen-move'
|| String(payload.playerId || '').trim() !== String(playerDeviceId || '').trim()) {
return res.status(401).json({ error: 'Invalid screen move authorization.' });
}
res.setHeader('Set-Cookie', `pulse-screen-move=${encodeURIComponent(token)}; Max-Age=60; Path=/; HttpOnly; SameSite=Lax`);
return res.json({ ok: true });
});
function requirePageAuth(allowedScopes) {
return function (req, res, next) {
if (!sharedSecret) {
@@ -296,7 +370,7 @@ function registerPlayerRoutes(app, options) {
}
});
app.get('/screen/:slug', function (req, res) {
app.get('/screen/:slug', async function (req, res, next) {
if (onPlayerPublicBaseUrl) {
try {
onPlayerPublicBaseUrl(getPlayerPublicBaseUrl(req, null));
@@ -321,7 +395,7 @@ function registerPlayerRoutes(app, options) {
res.set('X-Player-Offline', '1');
return res.send(common.renderPlayerPage(req.params.slug, null));
}
res.send(common.renderPlayerPage(req.params.slug, data));
res.send(common.renderPlayerPage(req.params.slug, null));
}).catch(function (error) {
if (!isBridgeFetchError(error)) {
console.error(error);
@@ -332,15 +406,8 @@ function registerPlayerRoutes(app, options) {
return;
}
const { bindPlayerToScreen } = require('./onboarding');
if (playerDeviceId) {
void bindPlayerToScreen(pool, playerDeviceId, req.params.slug)
.catch(function (error) {
console.error(error);
});
}
playerPlaylistService.buildScreenPlaylist(req.params.slug).then(function (data) {
res.send(common.renderPlayerPage(req.params.slug, data));
res.send(common.renderPlayerPage(req.params.slug, null));
}).catch(function (error) {
console.error(error);
res.set('X-Player-Offline', '1');
@@ -405,6 +472,9 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
try {
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/playlist', {
method: 'GET'
@@ -448,6 +518,9 @@ function registerPlayerRoutes(app, options) {
app.get('/api/screens/:slug/announcement', requirePageAuth(['player']), async function (req, res, next) {
try {
if (playerDeviceId && !(await isClientAuthorizedForScreen(req, req.params.slug))) {
return res.status(403).json({ error: 'This browser tab is not authorized for this screen.' });
}
if (bridgeBaseUrl) {
const response = await fetchBridge(req, '/api/screens/' + encodeURIComponent(req.params.slug) + '/announcement', {
method: 'GET'
+22 -1
View File
@@ -32,6 +32,21 @@ function createPlayerRuntime(options) {
const announcementListenersBySlug = new Map();
const wss = new WebSocketServer({ noServer: true });
function hasActiveClientId(clientId, currentConnection) {
const normalizedClientId = String(clientId || '').trim();
if (!normalizedClientId) {
return false;
}
for (const connections of connectionsBySlug.values()) {
for (const connection of connections.values()) {
if (connection !== currentConnection && String(connection.clientId || '').trim() === normalizedClientId) {
return true;
}
}
}
return false;
}
function normalizeClientIp(value) {
const ip = String(value || '').trim();
if (!ip) {
@@ -534,7 +549,13 @@ function createPlayerRuntime(options) {
return;
}
connection.clientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
const nextClientId = payload.clientId ? String(payload.clientId).trim() : connection.clientId;
if (nextClientId && hasActiveClientId(nextClientId, connection)) {
socket.send(JSON.stringify({ type: 'client-id-conflict' }));
socket.close(4009, 'Client ID is already in use.');
return;
}
connection.clientId = nextClientId;
connection.clientName = payload.clientName ? String(payload.clientName).trim() : connection.clientName;
connection.deviceId = payload.deviceId ? normalizeDeviceId(payload.deviceId) || connection.deviceId : connection.deviceId;
if (!connection.clientName && connection.clientId) {
+8
View File
@@ -22,6 +22,14 @@ const PERMISSION_SECTIONS = [
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
]
},
{
key: 'pairing',
order: 30,
name: 'Player pairing',
permissions: [
{ key: 'allow', name: 'Allow', description: 'Pair players with screen groups.' }
]
}
]
},
+1
View File
@@ -163,6 +163,7 @@ async function start() {
broadcastDashboardState: broadcastDashboardState,
dataSourceTasks: dataSourceTasks,
playerActionService: playerActionService,
playerInternalBaseUrl: webConfig.playerInternalUrl,
isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation,
requirePermission: function (permissionKey, options) {
@@ -3,7 +3,7 @@ const TASK = {
title: 'Onboarding device prune',
category: 'cleanup',
trigger: 'scheduled recurring task, hourly',
purpose: 'remove stale onboarding device bindings that have been idle for more than one minute.',
purpose: 'remove unbound onboarding devices that have been idle for more than one minute.',
taskType: 'recurring-run',
intervalMs: 60 * 60 * 1000
};
+11 -1
View File
@@ -25,7 +25,17 @@ function enrichScreensWithConnections(screens, connectionsBySlug, onboardingName
function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerIdentifierByBaseUrl, formatDashboardDate) {
return (screens || []).flatMap(function (screen) {
const connectionState = connectionsBySlug[screen.slug] || { count: 0, connections: [] };
return (connectionState.connections || []).map(function (connection) {
const seenClientKeys = new Set();
return (connectionState.connections || []).filter(function (connection) {
const deviceId = String(connection && connection.deviceId || '').trim();
const clientId = String(connection && connection.clientId || '').trim();
const key = deviceId && clientId ? `${deviceId}:${clientId}` : String(connection && connection.id || '').trim();
if (seenClientKeys.has(key)) {
return false;
}
seenClientKeys.add(key);
return true;
}).map(function (connection) {
const deviceId = String(connection.deviceId || '').trim();
const playerBaseUrl = normalizePlayerBaseUrl(connection.playerPublicBaseUrl);
return Object.assign({}, connection, {
@@ -186,6 +186,7 @@ function buildThumbnailPreviewPayload(slide, options) {
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl)
+1
View File
@@ -311,6 +311,7 @@ function buildThumbnailPreviewPayload(slide, options) {
canvasWidth: canvasSize.width,
canvasHeight: canvasSize.height,
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
fontStylesheetHref: String(options && options.fontStylesheetHref || '').trim(),
html: buildThumbnailPreviewMarkup(slide, options && options.baseUrl, options)
+38 -22
View File
@@ -216,35 +216,51 @@ function createPlayerActionService(options) {
}
async function forwardAnnouncementRefresh(slug) {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
body: { command: 'announcement-refresh' }
});
const resolvedPlayerInternalBaseUrl = await getPlayerInternalBaseUrl();
if (!resolvedPlayerInternalBaseUrl) {
const targetBaseUrls = Array.from(new Set([
resolvedPlayerInternalBaseUrl,
configuredBridgeInternalBaseUrl
].map(normalizeBaseUrl).filter(Boolean)));
if (!targetBaseUrls.length) {
throw new Error('Unable to resolve the player internal base URL.');
}
const response = await fetch(`${resolvedPlayerInternalBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify({ command: 'announcement-refresh' })
});
const results = await Promise.allSettled(targetBaseUrls.map(async function (targetBaseUrl) {
const authHeaders = createRequestAuthHeaders({
method: 'POST',
pathname: `/api/screens/${encodeURIComponent(slug)}/announcements/refresh`,
body: { command: 'announcement-refresh' }
});
const response = await fetch(`${targetBaseUrl}/api/screens/${encodeURIComponent(slug)}/announcements/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
...authHeaders
},
body: JSON.stringify({ command: 'announcement-refresh' })
});
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
error.statusCode = response.status;
throw error;
if (!response.ok) {
const errorText = await response.text().catch(function () { return ''; });
const error = new Error(errorText || `Unable to refresh announcements for player ${slug}.`);
error.statusCode = response.status;
throw error;
}
return response.json().catch(function () {
return { ok: true };
});
}));
const successfulResults = results.filter(function (result) {
return result.status === 'fulfilled';
});
if (!successfulResults.length) {
throw (results[0] && results[0].reason) || new Error(`Unable to refresh announcements for player ${slug}.`);
}
return response.json().catch(function () {
return { ok: true };
return successfulResults.map(function (result) {
return result.value;
});
}
+168 -1
View File
@@ -893,6 +893,103 @@
background: var(--bs-tertiary-bg);
}
.onboarding-pairing-card > .card-header {
padding: 0.75rem 1rem;
}
.onboarding-pairing-card .card-body {
padding: 0.75rem 1rem;
}
.onboarding-pairing-card.is-pairing .card-body > :not(.onboarding-pairing-progress) {
opacity: 0.55;
}
.onboarding-pairing-progress {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 1rem;
color: var(--bs-primary);
}
.onboarding-pairing-card > .card-footer {
padding: 0.5rem 1rem;
}
@media (max-width: 575.98px) {
.onboarding-client-list-link {
flex-basis: 100%;
text-align: center;
}
}
.onboarding-pin-inputs {
display: flex;
width: fit-content;
max-width: 100%;
margin-inline: auto;
}
.onboarding-pin-inputs .form-control {
flex: 1 1 0;
width: auto;
min-width: 0;
max-width: 3.5rem;
height: 3.5rem;
padding: 0.25rem;
text-align: center;
font-size: 1.5rem;
line-height: 1;
border-radius: 0;
}
.onboarding-pin-inputs .form-control + .form-control {
margin-left: -1px;
}
.onboarding-pin-inputs .form-control:first-child {
border-radius: var(--bs-border-radius) 0 0 var(--bs-border-radius);
}
.onboarding-pin-inputs .form-control:last-child {
border-radius: 0 var(--bs-border-radius) var(--bs-border-radius) 0;
}
.onboarding-pin-inputs .form-control:focus {
position: relative;
z-index: 1;
}
.onboarding-scanner {
position: fixed;
z-index: 1050;
inset: 0;
display: grid;
place-items: center;
padding: 1rem;
background: rgba(0, 0, 0, 0.68);
}
.onboarding-scanner-panel {
width: min(100%, 32rem);
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-radius: var(--bs-border-radius-lg);
background: var(--bs-body-bg);
box-shadow: var(--bs-box-shadow-lg);
}
.onboarding-scanner-video {
display: block;
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: var(--bs-border-radius);
background: #000;
}
.timetable-entries-table-shell {
overflow: hidden;
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
@@ -1114,11 +1211,15 @@
.screen-command-panel {
display: grid;
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr) minmax(12rem, 0.7fr);
gap: 1rem;
align-items: start;
}
.screen-command-panel-pairing-only {
grid-template-columns: minmax(0, 1fr);
}
.screen-command-panel-left {
display: grid;
gap: 0.85rem;
@@ -1161,6 +1262,28 @@
line-height: 1.35;
}
.screen-command-pairing {
display: grid;
gap: 0.65rem;
align-content: start;
padding: 0.9rem 0 0.9rem 1rem;
border-left: 1px solid var(--bs-border-color);
}
.screen-command-pairing > div {
display: grid;
gap: 0.25rem;
}
.screen-command-pairing .btn {
justify-self: start;
}
.screen-command-panel-pairing-only .screen-command-pairing {
padding-left: 0;
border-left: 0;
}
.screen-command-pill {
display: inline-flex;
align-items: center;
@@ -1206,6 +1329,11 @@
border-left: 0;
}
.screen-command-pairing {
padding: 0;
border-left: 0;
}
.screen-command-actions {
justify-content: stretch;
}
@@ -3330,4 +3458,43 @@ table.table thead th.sort-desc .table-sort-indicator {
.weather-preview-hourly-item {
width: 7.5rem;
}
.gradient-stop-bar {
position: relative;
height: 2.25rem;
padding: 0.45rem 0;
cursor: crosshair;
}
.gradient-stop-bar-track {
height: 1.35rem;
border: 1px solid var(--bs-border-color);
border-radius: 0.35rem;
background: var(--bs-secondary-bg);
}
.gradient-stop-bar-handles {
position: absolute;
inset: 0 0.25rem;
pointer-events: none;
}
.gradient-stop-handle {
position: absolute;
top: 0.1rem;
width: 1rem;
height: 2rem;
padding: 0;
border: 2px solid var(--bs-body-bg);
border-radius: 0.35rem;
box-shadow: 0 0 0 1px var(--bs-body-color);
transform: translateX(-50%);
cursor: grab;
pointer-events: auto;
touch-action: none;
}
.gradient-stop-handle:active {
cursor: grabbing;
}
+28 -1
View File
@@ -77,6 +77,28 @@
});
}
function updateDataSourceToggle(form, response) {
var toggleButton = document.querySelector('button[form="' + form.id + '"][data-async-data-source-toggle]');
if (!toggleButton) {
return;
}
var willEnable = toggleButton.getAttribute('data-enabled') !== 'true';
toggleButton.setAttribute('data-enabled', willEnable ? 'true' : 'false');
toggleButton.className = toggleButton.className.replace(/btn-(danger|success)/g, willEnable ? 'btn-danger' : 'btn-success');
toggleButton.innerHTML = '<i class="bi ' + (willEnable ? 'bi-pause-fill' : 'bi-play-fill') + ' me-1" aria-hidden="true"></i>' + (willEnable ? 'Disable' : 'Enable');
var message = '';
try {
message = new URL(response.url, window.location.href).searchParams.get('message') || '';
} catch (_error) {
message = '';
}
if (typeof showToast === 'function') {
showToast(message || (willEnable ? 'Data source enabled.' : 'Data source disabled.'), 'success');
}
}
function initDeleteButtons() {
document.addEventListener('click', function (event) {
var deleteButton = event.target.closest('[data-delete-action-url]');
@@ -610,7 +632,7 @@
} catch (_error) {
actionPath = String(form.action || '');
}
var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath) || /^\/data-sources\/(?:api-sources|rss-feeds|weather)\/\d+$/.test(actionPath);
var shouldReloadAfterSuccess = /^\/announcements\/\d+\/(?:play|stop)$/.test(actionPath);
event.preventDefault();
@@ -641,6 +663,11 @@
return;
}
if (/^\/data-sources\/(?:api-sources|rss-feeds|weather)\/\d+$/.test(actionPath)) {
updateDataSourceToggle(form, response);
return;
}
if (shouldReloadAfterSuccess && response && response.ok) {
window.location.reload();
return;
+88 -12
View File
@@ -7,7 +7,9 @@
var setButtonVariant = webUiHelpers.setButtonVariant;
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
var LIST_PAGE_SIZE = 25;
var CLIENT_ROW_REMOVAL_DELAY_MS = 1000;
var latestDashboardState = null;
var pendingClientRowRemovals = {};
function getClientSearchInput() {
var table = document.getElementById('dashboard-clients-table');
@@ -276,6 +278,21 @@
return compareClientSortValues(leftValue, rightValue);
}
function compareClientNames(leftValue, rightValue) {
var leftName = String(leftValue || '').trim();
var rightName = String(rightValue || '').trim();
if (!leftName && !rightName) {
return 0;
}
if (!leftName) {
return 1;
}
if (!rightName) {
return -1;
}
return leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
}
var sortKeys = accessors[normalizedSortKey]
? [normalizedSortKey]
: ['client'];
@@ -283,7 +300,9 @@
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (var index = 0; index < sortKeys.length; index += 1) {
var sortKeyName = sortKeys[index];
var comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
var comparison = sortKeyName === 'client'
? compareClientNames(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient))
: compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
if (comparison !== 0) {
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
@@ -336,7 +355,7 @@
form: document.getElementById('client-move-screen-form'),
targetSelect: document.getElementById('client-move-screen-target'),
connectionInput: document.querySelector('[data-client-move-connection-id]'),
deviceInput: document.querySelector('[data-client-move-device-id]'),
clientIdInput: document.querySelector('[data-client-move-client-id]'),
clientNameInput: document.querySelector('[data-client-move-client-name]'),
playerBaseUrlInput: document.querySelector('[data-client-move-player-base-url]')
};
@@ -350,7 +369,7 @@
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
var connectionId = String(row.getAttribute('data-client-id') || '').trim();
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
var clientId = String(row.getAttribute('data-client-client-id') || '').trim();
var playerBaseUrl = String(row.getAttribute('data-client-player-base-url') || '').trim();
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
@@ -367,8 +386,8 @@
if (elements.connectionInput) {
elements.connectionInput.value = connectionId;
}
if (elements.deviceInput) {
elements.deviceInput.value = deviceId;
if (elements.clientIdInput) {
elements.clientIdInput.value = clientId;
}
if (elements.clientNameInput) {
elements.clientNameInput.value = clientName;
@@ -687,11 +706,6 @@
&& typeof tbody.removeChild === 'function'
&& typeof document.createElement === 'function';
if (!visibleClients.length) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
return;
}
if (!canPatchRows) {
tbody.innerHTML = visibleClients.map(function (client) {
return renderClientRow(client, hasActionsColumn);
@@ -704,8 +718,32 @@
existingRows[String(row.getAttribute('data-client-key') || '').trim()] = row;
});
var visibleRowKeys = {};
visibleClients.forEach(function (client) {
visibleRowKeys[String(getClientRowKey(client) || '').trim()] = true;
});
function getClientIdentity(client) {
return [
String(client && (client.client_name || client.name || '') || '').trim().toLowerCase(),
String(client && (client.player_url || client.playerPublicBaseUrl || '') || '').trim().replace(/\/$/, '').toLowerCase()
].join('|');
}
var visibleClientIdentities = {};
visibleClients.forEach(function (client) {
var identity = getClientIdentity(client);
if (identity !== '|') {
visibleClientIdentities[identity] = true;
}
});
var nextRows = visibleClients.map(function (client) {
var rowKey = String(getClientRowKey(client) || '').trim();
if (pendingClientRowRemovals[rowKey]) {
clearTimeout(pendingClientRowRemovals[rowKey]);
delete pendingClientRowRemovals[rowKey];
}
var row = existingRows[rowKey] || null;
if (!row) {
@@ -719,6 +757,44 @@
return Boolean(row);
});
Object.keys(existingRows).forEach(function (rowKey) {
if (visibleRowKeys[rowKey]) {
return;
}
var existingRow = existingRows[rowKey];
var existingNameCell = existingRow.querySelector('td[data-label="Client"] > div');
var existingIdentity = [
String(existingNameCell && existingNameCell.textContent || '').trim().toLowerCase(),
String(existingRow.getAttribute('data-client-player-base-url') || '').trim().replace(/\/$/, '').toLowerCase()
].join('|');
if (existingIdentity !== '|' && visibleClientIdentities[existingIdentity]) {
if (pendingClientRowRemovals[rowKey]) {
clearTimeout(pendingClientRowRemovals[rowKey]);
delete pendingClientRowRemovals[rowKey];
}
if (existingRow.parentNode === tbody) {
tbody.removeChild(existingRow);
}
return;
}
if (pendingClientRowRemovals[rowKey]) {
return;
}
pendingClientRowRemovals[rowKey] = setTimeout(function () {
delete pendingClientRowRemovals[rowKey];
var row = existingRows[rowKey];
if (row && row.parentNode === tbody && !visibleRowKeys[rowKey]) {
tbody.removeChild(row);
}
if (!tbody.querySelector('tr[data-client-key]')) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
}
}, CLIENT_ROW_REMOVAL_DELAY_MS);
});
nextRows.forEach(function (row, index) {
var referenceNode = tbody.children[index] || null;
if (referenceNode !== row) {
@@ -726,8 +802,8 @@
}
});
while (tbody.children.length > nextRows.length) {
tbody.removeChild(tbody.lastElementChild);
if (!visibleClients.length && !tbody.querySelector('tr[data-client-key]')) {
tbody.innerHTML = '<tr><td colspan="' + (hasActionsColumn ? '7' : '6') + '" class="empty">No connected clients yet.</td></tr>';
}
}
+431
View File
@@ -0,0 +1,431 @@
(function () {
var form = document.getElementById('onboarding-pair-form');
var pairingCard = document.getElementById('onboarding-pair-card');
var pairingProgress = document.getElementById('onboarding-pair-progress');
var message = document.getElementById('onboarding-pair-message');
var codeInputsContainer = document.getElementById('onboarding-pair-code-inputs');
var codeField = document.getElementById('onboarding-pair-code');
var clientIdField = document.getElementById('onboarding-pair-client-id');
var anotherButton = document.getElementById('onboarding-pair-another');
var anotherButtonLabel = document.getElementById('onboarding-pair-another-label');
var manualButton = document.getElementById('onboarding-pair-manual');
var connectButton = document.getElementById('onboarding-pair-connect');
var clientListButton = document.getElementById('onboarding-pair-client-list');
var scanner = document.getElementById('onboarding-scanner');
var scannerVideo = document.getElementById('onboarding-scanner-video');
var scannerCapture = document.getElementById('onboarding-scanner-capture');
var scannerClose = document.getElementById('onboarding-scanner-close');
var scannerMessage = document.getElementById('onboarding-scanner-message');
var scannerDebugOutput = document.getElementById('onboarding-scanner-debug');
var scannerDebug = new URLSearchParams(window.location.search).get('scanner-debug') === '1';
if (!form || !message) {
return;
}
function getClientId() {
var storageKey = 'pulse-signage-player-client-id';
var clientId = clientIdField ? String(clientIdField.value || '').trim() : '';
if (!clientId) {
try { clientId = String(window.sessionStorage.getItem(storageKey) || '').trim(); } catch (_error) {}
}
if (!clientId) {
clientId = window.crypto && typeof window.crypto.randomUUID === 'function'
? window.crypto.randomUUID()
: 'client-' + Date.now().toString(36) + '-' + Math.random().toString(36).slice(2, 12);
try { window.sessionStorage.setItem(storageKey, clientId); } catch (_error) {}
}
if (clientIdField) { clientIdField.value = clientId; }
return clientId;
}
getClientId();
function traceScanner(value) {
if (!scannerDebug || !scannerDebugOutput) { return; }
scannerDebugOutput.classList.remove('d-none');
var lines = (scannerDebugOutput.textContent ? scannerDebugOutput.textContent.split('\n') : []).filter(Boolean);
lines.push(new Date().toISOString().slice(11, 19) + ' ' + value);
scannerDebugOutput.textContent = lines.slice(-8).join('\n');
}
var codeInputs = codeInputsContainer ? Array.prototype.slice.call(codeInputsContainer.querySelectorAll('[data-pairing-code-input]')) : [];
function normalizeCode(value) {
return String(value || '').replace(/[^a-z0-9]/gi, '').toUpperCase().slice(0, codeInputs.length);
}
function syncCodeField() {
if (codeField) {
codeField.value = codeInputs.map(function (input) { return input.value; }).join('').toUpperCase();
}
}
function setCode(value, startIndex) {
var normalized = normalizeCode(value);
var index = startIndex || 0;
normalized.split('').forEach(function (character) {
if (index < codeInputs.length) {
codeInputs[index].value = character;
index += 1;
}
});
syncCodeField();
if (index < codeInputs.length) {
codeInputs[index].focus();
} else if (codeInputs.length) {
codeInputs[codeInputs.length - 1].focus();
}
}
var scannerStream = null;
var scannerFrame = null;
var scannerLastDecodeAt = 0;
var scannerCanvas = document.createElement('canvas');
var scannerWorkCanvas = document.createElement('canvas');
var scannerDecodeCanvas = document.createElement('canvas');
function decodeQrCanvas(canvas) {
if (typeof window.jsQR !== 'function') { return null; }
var workCanvas = canvas;
var maximumDimension = 1024;
if (Math.max(canvas.width, canvas.height) > maximumDimension) {
var scale = maximumDimension / Math.max(canvas.width, canvas.height);
scannerWorkCanvas.width = Math.round(canvas.width * scale);
scannerWorkCanvas.height = Math.round(canvas.height * scale);
scannerWorkCanvas.getContext('2d').drawImage(canvas, 0, 0, scannerWorkCanvas.width, scannerWorkCanvas.height);
workCanvas = scannerWorkCanvas;
}
var context = workCanvas.getContext('2d', { willReadFrequently: true });
var imageData = context.getImageData(0, 0, workCanvas.width, workCanvas.height);
var result = window.jsQR(imageData.data, workCanvas.width, workCanvas.height, { inversionAttempts: 'attemptBoth' });
if (result) { return result; }
scannerDecodeCanvas.width = workCanvas.width;
scannerDecodeCanvas.height = workCanvas.height;
var decodeContext = scannerDecodeCanvas.getContext('2d', { willReadFrequently: true });
var source = new Uint8ClampedArray(imageData.data);
var cleaned = new Uint8ClampedArray(source.length);
for (var cleanY = 0; cleanY < workCanvas.height; cleanY += 1) {
for (var cleanX = 0; cleanX < workCanvas.width; cleanX += 1) {
var cleanSamples = [];
for (var cleanOffsetY = -1; cleanOffsetY <= 1; cleanOffsetY += 1) {
for (var cleanOffsetX = -1; cleanOffsetX <= 1; cleanOffsetX += 1) {
var cleanSampleX = Math.max(0, Math.min(workCanvas.width - 1, cleanX + cleanOffsetX));
var cleanSampleY = Math.max(0, Math.min(workCanvas.height - 1, cleanY + cleanOffsetY));
cleanSamples.push(source[(cleanSampleY * workCanvas.width + cleanSampleX) * 4]);
}
}
cleanSamples.sort(function (left, right) { return left - right; });
var cleanIndex = (cleanY * workCanvas.width + cleanX) * 4;
cleaned[cleanIndex] = cleaned[cleanIndex + 1] = cleaned[cleanIndex + 2] = cleanSamples[4];
cleaned[cleanIndex + 3] = 255;
}
}
source = cleaned;
var expanded = decodeContext.createImageData(workCanvas.width, workCanvas.height);
var radius = Math.max(3, Math.round(workCanvas.width / 205));
for (var y = 0; y < workCanvas.height; y += 1) {
for (var x = 0; x < workCanvas.width; x += 1) {
var samples = [];
for (var offsetY = -radius; offsetY <= radius; offsetY += 2) {
for (var offsetX = -radius; offsetX <= radius; offsetX += 2) {
var sampleX = Math.max(0, Math.min(workCanvas.width - 1, x + offsetX));
var sampleY = Math.max(0, Math.min(workCanvas.height - 1, y + offsetY));
samples.push(source[(sampleY * workCanvas.width + sampleX) * 4]);
}
}
var maximum = Math.max.apply(null, samples);
var index = (y * workCanvas.width + x) * 4;
expanded.data[index] = maximum;
expanded.data[index + 1] = maximum;
expanded.data[index + 2] = maximum;
expanded.data[index + 3] = 255;
}
}
decodeContext.putImageData(expanded, 0, 0);
return window.jsQR(expanded.data, workCanvas.width, workCanvas.height, { inversionAttempts: 'attemptBoth' });
}
function closeScanner() {
if (scannerFrame) { window.cancelAnimationFrame(scannerFrame); scannerFrame = null; }
if (scannerStream) {
scannerStream.getTracks().forEach(function (track) { track.stop(); });
scannerStream = null;
}
if (scannerVideo) {
scannerVideo.srcObject = null;
scannerVideo.classList.remove('d-none');
}
if (scannerCapture) { scannerCapture.value = ''; }
if (scanner) {
scanner.classList.add('d-none');
scanner.setAttribute('aria-hidden', 'true');
}
}
function resetPairingForm() {
form.reset();
codeInputsContainer.setAttribute('data-pairing-code', '');
codeInputs.forEach(function (input) { input.value = ''; input.disabled = false; });
if (codeField) { codeField.value = ''; }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = false; }
});
if (connectButton) { connectButton.classList.remove('d-none'); }
if (clientListButton) { clientListButton.classList.add('d-none'); }
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
message.className = 'alert d-none';
message.textContent = '';
if (anotherButton) {
anotherButton.classList.remove('btn-secondary');
anotherButton.classList.add('btn-outline-secondary');
anotherButton.setAttribute('aria-label', 'Scan player QR code');
anotherButton.setAttribute('title', 'Scan player QR code');
}
if (anotherButtonLabel) { anotherButtonLabel.classList.add('d-none'); }
if (manualButton) { manualButton.classList.add('d-none'); }
}
function codeFromScan(rawValue) {
try {
var scannedUrl = new URL(String(rawValue || ''), window.location.origin);
if (scannedUrl.pathname === '/pairing') {
return normalizeCode(scannedUrl.searchParams.get('code'));
}
} catch (_error) {}
return normalizeCode(rawValue);
}
function applyScannedCode(rawValue) {
var code = codeFromScan(rawValue);
if (code.length !== codeInputs.length) {
scannerMessage.textContent = 'That QR code is not a Pulse Signage pairing code.';
return false;
}
closeScanner();
resetPairingForm();
setCode(code, 0);
return true;
}
function scanFrame() {
if (!scannerVideo || !scannerStream) { return; }
if (Date.now() - scannerLastDecodeAt < 400) {
scannerFrame = window.requestAnimationFrame(scanFrame);
return;
}
scannerLastDecodeAt = Date.now();
if (!window.BarcodeDetector && typeof window.jsQR === 'function') {
var width = scannerVideo.videoWidth;
var height = scannerVideo.videoHeight;
if (width && height) {
traceScanner('frame ' + width + 'x' + height);
scannerCanvas.width = width;
scannerCanvas.height = height;
var context = scannerCanvas.getContext('2d', { willReadFrequently: true });
context.drawImage(scannerVideo, 0, 0, width, height);
var result = decodeQrCanvas(scannerCanvas);
traceScanner(result ? 'decoded' : 'no QR');
if (result && applyScannedCode(result.data)) { return; }
scannerFrame = window.requestAnimationFrame(scanFrame);
return;
}
scannerFrame = window.requestAnimationFrame(scanFrame);
return;
}
scannerVideo.__pairingBarcodeDetector.detect(scannerVideo).then(function (barcodes) {
if (barcodes.length) {
if (applyScannedCode(barcodes[0].rawValue)) { return; }
}
scannerFrame = window.requestAnimationFrame(scanFrame);
}).catch(function () {
scannerFrame = window.requestAnimationFrame(scanFrame);
});
}
function openScanner() {
if (!scanner || !scannerVideo || (!window.BarcodeDetector && typeof window.jsQR !== 'function')) {
message.className = 'alert alert-warning';
message.textContent = 'Camera scanning is not supported by this browser.';
return;
}
scannerMessage.textContent = 'Point your camera at the player QR code.';
traceScanner('open BarcodeDetector=' + Boolean(window.BarcodeDetector) + ' jsQR=' + (typeof window.jsQR === 'function'));
scannerVideo.classList.remove('d-none');
scanner.classList.remove('d-none');
scanner.setAttribute('aria-hidden', 'false');
if (window.BarcodeDetector) {
scannerVideo.__pairingBarcodeDetector = new window.BarcodeDetector({ formats: ['qr_code'] });
}
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
traceScanner('getUserMedia unavailable');
scannerVideo.classList.add('d-none');
scannerMessage.textContent = 'Use the camera to capture the player QR code.';
if (scannerCapture) { scannerCapture.click(); }
return;
}
navigator.mediaDevices.getUserMedia({
video: {
facingMode: { ideal: 'environment' },
width: { ideal: 1280 },
height: { ideal: 720 },
focusMode: { ideal: 'continuous' }
},
audio: false
}).then(function (stream) {
scannerStream = stream;
traceScanner('stream opened');
traceScanner(JSON.stringify(stream.getVideoTracks()[0].getSettings ? stream.getVideoTracks()[0].getSettings() : {}));
scannerVideo.srcObject = stream;
return scannerVideo.play();
}).then(function () {
scannerFrame = window.requestAnimationFrame(scanFrame);
}).catch(function () {
traceScanner('stream failed');
scannerVideo.classList.add('d-none');
scannerMessage.textContent = 'Use the camera to capture the player QR code.';
if (scannerCapture) { scannerCapture.click(); }
});
}
if (codeInputs.length) {
setCode(codeInputsContainer.getAttribute('data-pairing-code'), 0);
codeInputs.forEach(function (input, index) {
input.addEventListener('input', function () {
var value = normalizeCode(input.value);
input.value = value.slice(0, 1);
if (value.length > 1) {
setCode(value, index);
} else {
syncCodeField();
if (input.value && index < codeInputs.length - 1) {
codeInputs[index + 1].focus();
}
}
});
input.addEventListener('paste', function (event) {
event.preventDefault();
setCode(event.clipboardData ? event.clipboardData.getData('text') : '', index);
});
input.addEventListener('keydown', function (event) {
if (event.key === 'Backspace' && !input.value && index > 0) {
codeInputs[index - 1].focus();
codeInputs[index - 1].value = '';
syncCodeField();
} else if (event.key === 'ArrowLeft' && index > 0) {
event.preventDefault();
codeInputs[index - 1].focus();
} else if (event.key === 'ArrowRight' && index < codeInputs.length - 1) {
event.preventDefault();
codeInputs[index + 1].focus();
}
});
});
}
form.addEventListener('submit', function (event) {
event.preventDefault();
getClientId();
syncCodeField();
if (!codeField || codeField.value.length !== codeInputs.length) {
message.className = 'alert alert-warning';
message.textContent = 'Enter the complete six-character pairing code.';
var firstEmptyInput = codeInputs.filter(function (input) { return !input.value; })[0] || codeInputs[0];
if (firstEmptyInput) { firstEmptyInput.focus(); }
return;
}
message.className = 'alert alert-info';
message.textContent = 'Pairing player...';
var pairingBody = new URLSearchParams(new FormData(form)).toString();
if (pairingCard) { pairingCard.classList.add('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.remove('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
});
fetch(form.action, {
method: 'POST',
headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8' },
body: pairingBody
}).then(function (response) {
return response.text().then(function (text) {
var payload = null;
try { payload = JSON.parse(text); } catch (_error) {}
if (!response.ok) {
throw new Error(payload && payload.error ? payload.error : 'Unable to pair player.');
}
if (payload && payload.queued) {
message.className = 'alert alert-info';
message.textContent = 'Pairing is still being completed. Keep this page open and wait for confirmation.';
return;
}
message.className = 'alert alert-success';
message.textContent = 'Pairing saved. The player is loading your screen.';
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = true; }
});
if (connectButton) { connectButton.classList.add('d-none'); }
if (clientListButton) { clientListButton.classList.remove('d-none'); }
if (anotherButton) {
anotherButton.classList.remove('d-none');
anotherButton.classList.remove('btn-outline-secondary');
anotherButton.classList.add('btn-secondary');
anotherButton.setAttribute('aria-label', 'Pair another screen');
anotherButton.setAttribute('title', 'Pair another screen');
}
if (anotherButtonLabel) { anotherButtonLabel.classList.remove('d-none'); }
if (manualButton) { manualButton.classList.remove('d-none'); }
});
}).catch(function (error) {
if (pairingCard) { pairingCard.classList.remove('is-pairing'); }
if (pairingProgress) { pairingProgress.classList.add('d-none'); }
Array.prototype.slice.call(form.elements).forEach(function (element) {
if (element !== anotherButton && element !== manualButton) { element.disabled = false; }
});
message.className = 'alert alert-danger';
message.textContent = error && error.message ? error.message : 'Unable to pair player.';
});
});
if (anotherButton) { anotherButton.addEventListener('click', openScanner); }
if (manualButton) {
manualButton.addEventListener('click', function () {
resetPairingForm();
if (codeInputs[0]) { codeInputs[0].focus(); }
});
}
if (scannerClose) { scannerClose.addEventListener('click', closeScanner); }
if (scannerCapture) {
scannerCapture.addEventListener('change', function () {
var file = scannerCapture.files && scannerCapture.files[0];
if (!file) { return; }
scannerMessage.textContent = 'Reading QR code...';
var image = new Image();
image.onload = function () {
if (window.BarcodeDetector) {
new window.BarcodeDetector({ formats: ['qr_code'] }).detect(image).then(function (barcodes) {
if (!barcodes.length || !applyScannedCode(barcodes[0].rawValue)) {
scannerMessage.textContent = 'Could not find a Pulse Signage QR code in that image.';
}
}).catch(function () {
scannerMessage.textContent = 'Could not read that QR image. Try again.';
});
return;
}
try {
var canvas = document.createElement('canvas');
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
var context = canvas.getContext('2d', { willReadFrequently: true });
context.drawImage(image, 0, 0);
var result = decodeQrCanvas(canvas);
if (!result || !applyScannedCode(result.data)) {
scannerMessage.textContent = 'Could not find a Pulse Signage QR code in that image.';
}
} catch (_error) {
scannerMessage.textContent = 'Could not read that QR image. Try again.';
}
};
image.onerror = function () {
scannerMessage.textContent = 'Could not read that QR image. Try again.';
};
var reader = new FileReader();
reader.onload = function () { image.src = reader.result; };
reader.onerror = function () { scannerMessage.textContent = 'Could not read that QR image. Try again.'; };
reader.readAsDataURL(file);
});
}
if (scanner) {
scanner.addEventListener('click', function (event) {
if (event.target === scanner) { closeScanner(); }
});
}
}());
+18 -3
View File
@@ -87,7 +87,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return templates.find(function (item) { return Number(item.id) === Number(id); }) || null;
}
function applyBackdropStyle(element, backgroundColor, backgroundImagePath) {
function applyBackdropStyle(element, backgroundColor, backgroundImagePath, backgroundGradient) {
if (!element) {
return;
}
@@ -107,7 +107,21 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var color = String(backgroundColor || '#111111').trim() || '#111111';
element.style.backgroundColor = color;
element.style.backgroundImage = 'none';
var gradient = '';
try {
var gradientData = typeof backgroundGradient === 'string' ? JSON.parse(backgroundGradient) : backgroundGradient;
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
var stops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
stops = stops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (stops.length >= 2) {
var angle = Number(gradientData.angle);
gradient = 'linear-gradient(' + (Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90) + 'deg,' + stops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
}
}
} catch (_error) {
gradient = '';
}
element.style.backgroundImage = gradient || 'none';
element.style.backgroundPosition = 'center';
element.style.backgroundSize = '100% 100%';
element.style.backgroundRepeat = 'no-repeat';
@@ -471,6 +485,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
canvasHeight: currentPreviewCanvasHeight || Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)),
backgroundColor: template && template.background_color ? String(template.background_color) : '#111111',
backgroundImagePath: template && template.background_image_path ? String(template.background_image_path) : '',
backgroundGradient: template && template.background_gradient ? String(template.background_gradient) : '',
fontStylesheetHref: fontStylesheetHref
};
}
@@ -591,7 +606,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
currentPreviewCanvasWidth = canvasWidth;
currentPreviewCanvasHeight = canvasHeight;
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path);
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path, template.background_gradient);
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) {
slidePreviewEmpty.style.display = 'flex';
@@ -33,6 +33,18 @@
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundGradientEnabled = document.getElementById('background-gradient-enabled');
var backgroundGradientInput = document.getElementById('background-gradient');
var backgroundGradientOptions = document.getElementById('background-gradient-options');
var backgroundGradientAngle = document.getElementById('background-gradient-angle');
var backgroundGradientAngleOutput = document.getElementById('background-gradient-angle-output');
var backgroundGradientBar = document.getElementById('background-gradient-bar');
var backgroundGradientBarHandles = document.getElementById('background-gradient-bar-handles');
var backgroundGradientStops = document.getElementById('background-gradient-stops');
var backgroundGradientAddStop = document.getElementById('background-gradient-add-stop');
var draggedGradientStopIndex = -1;
var draggedGradientStopRow = null;
var draggedGradientStopHandle = null;
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
@@ -1117,6 +1129,65 @@
return;
}
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
if (backgroundGradientEnabled && backgroundGradientEnabled.checked) {
var gradientStops = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (stop) {
return { color: stop.querySelector('[data-gradient-stop-color]').value, position: Number(stop.querySelector('[data-gradient-stop-position]').value || 0) };
});
var gradient = { type: 'linear', stops: gradientStops, angle: Number(backgroundGradientAngle.value || 90) };
backgroundGradientInput.value = JSON.stringify(gradient);
stage.style.backgroundImage = 'linear-gradient(' + gradient.angle + 'deg,' + gradient.stops.map(function (stop) { return stop.color + ' ' + stop.position + '%'; }).join(',') + ')';
var gradientBarTrack = backgroundGradientBar && backgroundGradientBar.querySelector('.gradient-stop-bar-track');
if (gradientBarTrack) gradientBarTrack.style.background = stage.style.backgroundImage;
backgroundGradientOptions.hidden = false;
} else {
backgroundGradientInput.value = '';
stage.style.backgroundImage = 'none';
var emptyGradientBarTrack = backgroundGradientBar && backgroundGradientBar.querySelector('.gradient-stop-bar-track');
if (emptyGradientBarTrack) emptyGradientBarTrack.style.background = 'var(--bs-secondary-bg)';
backgroundGradientOptions.hidden = true;
}
if (backgroundGradientAngleOutput) backgroundGradientAngleOutput.value = String(backgroundGradientAngle.value || 90);
}
function renderBackgroundGradientStops(stops) {
if (!backgroundGradientStops) return;
var normalizedStops = Array.isArray(stops) && stops.length >= 2 ? stops : [{ color: '#111111', position: 0 }, { color: '#334455', position: 100 }];
if (backgroundGradientBarHandles) {
backgroundGradientBarHandles.innerHTML = normalizedStops.map(function (stop, index) {
return '<button type="button" class="gradient-stop-handle" data-gradient-bar-index="' + index + '" style="left:' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%;background:' + escapeHtml(stop.color || '#111111') + ';" aria-label="Gradient stop ' + (index + 1) + '"></button>';
}).join('');
if (backgroundGradientBar) backgroundGradientBar.style.background = 'transparent';
}
backgroundGradientStops.innerHTML = normalizedStops.map(function (stop) {
return '<div class="d-flex align-items-end gap-2" data-gradient-stop><label class="flex-grow-1">Colour<input type="color" class="form-control form-control-color w-100" data-gradient-stop-color value="' + escapeHtml(stop.color || '#111111') + '" /></label><label style="width:6rem">Position<input type="number" class="form-control" data-gradient-stop-position min="0" max="100" value="' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '" /></label><button type="button" class="btn btn-outline-danger" data-gradient-stop-remove aria-label="Remove colour stop">&times;</button></div>';
}).join('');
updateGradientStopRemoveButtons();
}
function updateGradientStopRemoveButtons() {
if (!backgroundGradientStops) return;
var stops = backgroundGradientStops.querySelectorAll('[data-gradient-stop]');
Array.prototype.forEach.call(stops, function (stop) { stop.querySelector('[data-gradient-stop-remove]').disabled = stops.length <= 2; });
}
function syncBackgroundGradientBar() {
if (!backgroundGradientStops || !backgroundGradientBarHandles) return;
var rows = backgroundGradientStops.querySelectorAll('[data-gradient-stop]');
if (backgroundGradientBarHandles.children.length !== rows.length) {
backgroundGradientBarHandles.innerHTML = Array.prototype.map.call(rows, function (row, index) {
var color = row.querySelector('[data-gradient-stop-color]').value || '#111111';
var position = Math.max(0, Math.min(100, Number(row.querySelector('[data-gradient-stop-position]').value) || 0));
return '<button type="button" class="gradient-stop-handle" data-gradient-bar-index="' + index + '" style="left:' + position + '%;background:' + escapeHtml(color) + ';" aria-label="Gradient stop ' + (index + 1) + '"></button>';
}).join('');
return;
}
Array.prototype.forEach.call(rows, function (row, index) {
var handle = backgroundGradientBarHandles.children[index];
if (!handle) return;
handle.style.background = row.querySelector('[data-gradient-stop-color]').value || '#111111';
handle.style.left = Math.max(0, Math.min(100, Number(row.querySelector('[data-gradient-stop-position]').value) || 0)) + '%';
handle.setAttribute('data-gradient-bar-index', index);
});
}
function updateRegionLockBadge(card) {
@@ -1757,6 +1828,79 @@
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
if (backgroundGradientEnabled) backgroundGradientEnabled.addEventListener('change', updateStageBackgroundColor);
if (backgroundGradientAddStop) backgroundGradientAddStop.addEventListener('click', function () {
var stops = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (stop) {
return { color: stop.querySelector('[data-gradient-stop-color]').value, position: Number(stop.querySelector('[data-gradient-stop-position]').value || 0) };
});
stops.push({ color: '#ffffff', position: 100 });
renderBackgroundGradientStops(stops);
updateStageBackgroundColor();
});
if (backgroundGradientStops) backgroundGradientStops.addEventListener('input', function () {
syncBackgroundGradientBar();
updateStageBackgroundColor();
});
if (backgroundGradientStops) backgroundGradientStops.addEventListener('click', function (event) {
var removeButton = event.target.closest('[data-gradient-stop-remove]');
if (!removeButton || removeButton.disabled) return;
removeButton.closest('[data-gradient-stop]').remove();
updateGradientStopRemoveButtons();
syncBackgroundGradientBar();
updateStageBackgroundColor();
});
if (backgroundGradientBar) backgroundGradientBar.addEventListener('click', function (event) {
if (event.target.closest('[data-gradient-bar-index]')) return;
var rect = backgroundGradientBar.getBoundingClientRect();
var position = Math.max(0, Math.min(100, Math.round(((event.clientX - rect.left) / rect.width) * 100)));
var stops = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (stop) { return { color: stop.querySelector('[data-gradient-stop-color]').value, position: Number(stop.querySelector('[data-gradient-stop-position]').value || 0) }; });
stops.push({ color: '#ffffff', position: position });
stops.sort(function (left, right) { return left.position - right.position; });
renderBackgroundGradientStops(stops);
updateStageBackgroundColor();
});
if (backgroundGradientBarHandles) backgroundGradientBarHandles.addEventListener('pointerdown', function (event) {
var handle = event.target.closest('[data-gradient-bar-index]');
if (!handle) return;
draggedGradientStopIndex = Number(handle.getAttribute('data-gradient-bar-index'));
draggedGradientStopRow = backgroundGradientStops.querySelectorAll('[data-gradient-stop]')[draggedGradientStopIndex] || null;
draggedGradientStopHandle = handle;
handle.classList.add('is-dragging');
handle.setPointerCapture(event.pointerId);
event.preventDefault();
});
document.addEventListener('pointermove', function (event) {
if (draggedGradientStopIndex < 0) return;
var rect = backgroundGradientBar.getBoundingClientRect();
var position = Math.max(0, Math.min(100, Math.round(((event.clientX - rect.left) / rect.width) * 100)));
if (draggedGradientStopRow) draggedGradientStopRow.querySelector('[data-gradient-stop-position]').value = position;
if (draggedGradientStopHandle) draggedGradientStopHandle.style.left = position + '%';
var entries = Array.prototype.map.call(backgroundGradientStops.querySelectorAll('[data-gradient-stop]'), function (row, index) {
return { row: row, handle: backgroundGradientBarHandles.children[index], position: Number(row.querySelector('[data-gradient-stop-position]').value || 0), index: index };
});
entries.sort(function (left, right) { return left.position - right.position || left.index - right.index; });
entries.forEach(function (entry, index) {
backgroundGradientStops.appendChild(entry.row);
backgroundGradientBarHandles.appendChild(entry.handle);
entry.handle.setAttribute('data-gradient-bar-index', index);
});
draggedGradientStopIndex = entries.findIndex(function (entry) { return entry.row === draggedGradientStopRow; });
updateStageBackgroundColor();
});
document.addEventListener('pointerup', function () {
if (draggedGradientStopHandle) draggedGradientStopHandle.classList.remove('is-dragging');
draggedGradientStopIndex = -1;
draggedGradientStopRow = null;
draggedGradientStopHandle = null;
});
document.addEventListener('pointercancel', function () {
draggedGradientStopIndex = -1;
draggedGradientStopRow = null;
draggedGradientStopHandle = null;
});
[backgroundGradientAngle].forEach(function (input) {
if (input) input.addEventListener('input', updateStageBackgroundColor);
});
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
@@ -1858,6 +2002,19 @@
});
}
if (backgroundGradientInput && backgroundGradientInput.value) {
try {
var initialGradient = JSON.parse(backgroundGradientInput.value);
if (initialGradient) {
var initialStops = Array.isArray(initialGradient.stops) ? initialGradient.stops : (initialGradient.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
renderBackgroundGradientStops(initialStops);
if (backgroundGradientAngle && Number.isFinite(Number(initialGradient.angle))) backgroundGradientAngle.value = String(Math.max(0, Math.min(360, Number(initialGradient.angle))));
}
} catch (_error) {
backgroundGradientInput.value = '';
}
}
if (backgroundGradientStops && !backgroundGradientStops.children.length) renderBackgroundGradientStops();
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
+1 -6
View File
@@ -133,12 +133,7 @@
}
function getClientRowKey(client) {
var clientName = String(client && client.client_name ? client.client_name : '').trim();
if (clientName) {
return clientName;
}
return String(client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : client && client.screen_slug ? client.screen_slug : client && client.deviceId ? client.deviceId : '').trim();
}
function getClientDisplayName(client) {
+10102
View File
File diff suppressed because it is too large Load Diff
+128 -29
View File
@@ -1,6 +1,6 @@
// Admin client command routes for connected screens.
const { commitDeviceBinding } = require('#src/player/onboarding');
const { createPageAuthToken } = require('#src/request-auth');
module.exports = function registerScreenCommandRoutes(app, deps) {
const pool = deps.pool;
@@ -227,7 +227,9 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
if (command === 'setclientname') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const physicalDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
const clientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim();
const deviceId = physicalDeviceId || clientId || String((req.body && req.body.connectionId) || req.query.connectionId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
@@ -236,13 +238,26 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Client name is required' });
}
const [currentRows] = await pool.query(
let bindingDeviceId = deviceId;
let [currentRows] = await pool.query(
`SELECT client_name
FROM d_onboarding_devices
WHERE device_id = ?
LIMIT 1`,
[deviceId]
[clientId || deviceId]
);
if (!currentRows.length && clientId && physicalDeviceId) {
bindingDeviceId = physicalDeviceId;
[currentRows] = await pool.query(
`SELECT client_name
FROM d_onboarding_devices
WHERE device_id = ?
LIMIT 1`,
[physicalDeviceId]
);
} else if (clientId) {
bindingDeviceId = clientId;
}
const onboardingRow = currentRows[0] || null;
const currentName = String(onboardingRow && onboardingRow.client_name ? onboardingRow.client_name : '').trim();
if (currentName && currentName.toLowerCase() === clientName.toLowerCase()) {
@@ -282,7 +297,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
liveConnections = [];
}
const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections);
const available = await isClientNameAvailable(pool, clientName, bindingDeviceId, liveConnections);
if (!available) {
return res.status(409).json({ error: 'Client name already exists.' });
}
@@ -321,11 +336,10 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
const [updateResult] = await pool.query(
`UPDATE d_onboarding_devices pod
JOIN d_screens s ON s.id = pod.screen_id
SET pod.client_name = ?, pod.modified_at = CURRENT_TIMESTAMP
WHERE s.slug = ? AND pod.device_id = ?`,
[clientName, slug, deviceId]
`UPDATE d_onboarding_devices
SET client_name = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[clientName, bindingDeviceId]
);
if (!updateResult.affectedRows) {
return res.status(404).json({ error: 'Client not found' });
@@ -344,24 +358,43 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
}
if (command === 'moveclient') {
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId || req.body.connectionId)) || req.query.deviceId || req.query.clientId || req.query.connectionId || '').trim();
const legacyDeviceId = String((req.body && req.body.deviceId) || req.query.deviceId || '').trim();
let physicalPlayerId = '';
const tabClientId = String((req.body && req.body.clientId) || req.query.clientId || '').trim();
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
const submittedPlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
if (!deviceId) {
return res.status(400).json({ error: 'Device ID is required' });
if (!connectionId && !submittedPlayerBaseUrl && !legacyDeviceId) {
return res.status(400).json({ error: 'Client connection is required' });
}
if (!targetScreenSlug) {
return res.status(400).json({ error: 'Target screen is required' });
}
if (submittedPlayerBaseUrl) {
const [playerRows] = await pool.query(
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
[submittedPlayerBaseUrl]
);
const registeredPlayerId = String(playerRows[0] && playerRows[0].identifier || '').trim();
if (registeredPlayerId) {
physicalPlayerId = registeredPlayerId;
}
}
physicalPlayerId = physicalPlayerId || legacyDeviceId;
if (!physicalPlayerId) {
return res.status(400).json({ error: 'Registered player identity is required' });
}
const [currentRows] = await pool.query(
`SELECT d.client_name, s.slug AS current_screen_slug
FROM d_onboarding_devices d
LEFT JOIN d_screens s ON s.id = d.screen_id
WHERE d.device_id = ?
LIMIT 1`,
[deviceId]
[physicalPlayerId]
);
const onboardingRow = currentRows[0] || null;
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
@@ -371,13 +404,13 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
return res.status(400).json({ error: 'Client name is required' });
}
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
if (!connectionId && currentScreenSlug && currentScreenSlug === targetScreenSlug) {
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
deviceId: physicalPlayerId,
clientName: resolvedClientName,
targetScreenSlug: targetScreenSlug,
ok: true,
@@ -385,19 +418,78 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
});
}
if (typeof commitDeviceBinding !== 'function') {
return res.status(500).json({ error: 'Client binding is unavailable.' });
}
const liveConnections = await fetchScreenConnections(slug);
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
let status = null;
if (onboardingRow) {
const [targetRows] = await pool.query('SELECT id, name, slug FROM d_screens WHERE slug = ? LIMIT 1', [targetScreenSlug]);
if (!targetRows.length) {
return res.status(404).json({ error: 'Target screen not found' });
}
if (tabClientId) {
const [tabUpdateResult] = await pool.query(
`UPDATE d_onboarding_devices
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[resolvedClientName, targetRows[0].id, tabClientId]
);
if (!tabUpdateResult.affectedRows) {
await pool.query(
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
VALUES (?, ?, ?)`,
[tabClientId, resolvedClientName, targetRows[0].id]
);
}
} else {
await pool.query(
`UPDATE d_onboarding_devices
SET screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[targetRows[0].id, physicalPlayerId]
);
}
const stablePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl);
if (stablePlayerBaseUrl && !tabClientId) {
const [playerRows] = await pool.query(
'SELECT identifier FROM d_players WHERE public_base_url = ? LIMIT 1',
[stablePlayerBaseUrl]
);
const stablePlayer = playerRows[0] || null;
if (stablePlayer && stablePlayer.identifier) {
await pool.query(
`UPDATE d_onboarding_devices
SET client_name = ?, screen_id = ?, modified_at = CURRENT_TIMESTAMP
WHERE device_id = ?`,
[resolvedClientName, targetRows[0].id, stablePlayer.identifier]
);
await pool.query(
`INSERT INTO d_onboarding_devices (device_id, client_name, screen_id)
SELECT ?, ?, ?
WHERE NOT EXISTS (
SELECT 1 FROM d_onboarding_devices WHERE device_id = ?
)`,
[stablePlayer.identifier, resolvedClientName, targetRows[0].id, stablePlayer.identifier]
);
}
}
status = {
client_name: resolvedClientName,
screen_id: targetRows[0].id,
screen_slug: targetScreenSlug
};
} else {
status = {
client_name: resolvedClientName,
screen_id: null,
screen_slug: targetScreenSlug,
live_only: true
};
}
let targetPlayerUrl = '';
const liveConnection = Array.isArray(liveConnections)
? liveConnections.find(function (connection) {
const candidateConnectionId = String(connection && (connection.id || connection.clientId) || '').trim();
const candidateDeviceId = String(connection && connection.deviceId || '').trim();
return candidateConnectionId === connectionId || candidateConnectionId === deviceId || candidateDeviceId === deviceId;
return candidateConnectionId === connectionId || candidateConnectionId === physicalPlayerId || candidateDeviceId === physicalPlayerId;
}) || liveConnections[0] || null
: null;
const sourcePlayerBaseUrl = normalizeExplicitPlayerBaseUrl(playerBaseUrl || (liveConnection && liveConnection.playerPublicBaseUrl) || '');
@@ -407,17 +499,24 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
if (!targetPlayerUrl) {
targetPlayerUrl = `/screen/${encodeURIComponent(targetScreenSlug)}`;
}
const moveToken = createPageAuthToken({
scope: 'screen-move',
playerId: physicalPlayerId,
connectionId: connectionId || null,
screenSlug: targetScreenSlug
});
if (playerBaseUrl) {
await forwardPlayerCommandForPlayerBaseUrl(slug, playerBaseUrl, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined, deviceId || null);
url: targetPlayerUrl,
moveToken: moveToken || null
}, connectionId || physicalPlayerId || undefined, physicalPlayerId || null);
} else {
await forwardPlayerCommandForConnections(slug, liveConnections, {
command: 'redirect',
url: targetPlayerUrl
}, connectionId || deviceId || undefined, deviceId || null);
url: targetPlayerUrl,
moveToken: moveToken || null
}, connectionId || physicalPlayerId || undefined, physicalPlayerId || null);
}
if (typeof broadcastDashboardState === 'function') {
@@ -429,7 +528,7 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
deviceId: physicalPlayerId,
clientName: status ? status.client_name : resolvedClientName,
targetScreenSlug: targetScreenSlug,
playerUrl: targetPlayerUrl,
+4 -4
View File
@@ -632,8 +632,8 @@ module.exports = function registerContentRoutes(app, deps) {
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, actorId]
'INSERT INTO c_templates (name, canvas_size_id, background_image_path, background_color, background_gradient, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, actorId]
);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
@@ -707,8 +707,8 @@ module.exports = function registerContentRoutes(app, deps) {
regions: normalizeTemplateRegionsForAudit(payload.regions)
});
await pool.query(
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, actorId, template.id]
'UPDATE c_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, background_color = ?, background_gradient = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, payload.backgroundColor, payload.backgroundGradient, actorId, template.id]
);
await pool.query('DELETE FROM c_template_regions WHERE template_id = ?', [template.id]);
for (let i = 0; i < payload.regions.length; i += 1) {
+21
View File
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../lib/pagination');
const renderWeatherLocationsPage = require('./weather/list');
const renderWeatherLocationAddPage = require('./weather/add');
const renderWeatherLocationEditPage = require('./weather/edit');
const { buildDuplicateWeatherLocationName, buildDuplicateWeatherLocation } = require('./weather/duplicate');
const { fetchAppSettings } = require('../../../data/app-settings');
async function getWeatherLocationUsageIds(pool, common) {
@@ -85,6 +86,26 @@ module.exports = function registerWeatherRoutes(app, deps) {
} catch (error) { next(error); }
});
app.get('/data-sources/weather/:id/duplicate', requirePermission('weather.read'), requirePermission('weather.create'), async function (req, res, next) {
try {
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
if (!location) return res.status(404).send('Weather location not found');
let duplicateName = buildDuplicateWeatherLocationName(location.name);
let duplicateIndex = 2;
while (await common.fetchDuplicateName(pool, 'i_weather_locations', duplicateName)) {
duplicateName = buildDuplicateWeatherLocationName(location.name) + ' (' + duplicateIndex + ')';
duplicateIndex += 1;
}
res.send(renderWeatherLocationAddPage(buildDuplicateWeatherLocation(location, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
providerAvailability: await getProviderAvailability(),
messageVariant: 'info',
showSaveSecondaryActions: true
}));
} catch (error) { next(error); }
});
app.post('/data-sources/weather', requirePermission('weather.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
@@ -0,0 +1,22 @@
// Weather location duplication helpers.
function buildDuplicateWeatherLocationName(locationName) {
return 'Copy of ' + String(locationName || '').trim();
}
function buildDuplicateWeatherLocation(location, duplicateName) {
return Object.assign({}, location, {
id: null,
name: duplicateName,
last_pulled_at: null,
last_pull_error: '',
last_response_status: null,
last_response_content_type: '',
last_response_json: ''
});
}
module.exports = {
buildDuplicateWeatherLocationName,
buildDuplicateWeatherLocation
};
+98
View File
@@ -0,0 +1,98 @@
const { createRequestAuthHeaders } = require('#src/request-auth');
const { fetchPlayerRegistrations } = require('#src/data/player-registry');
const { renderView } = require('../view');
module.exports = function registerOnboardingRoutes(app, deps) {
const pool = deps.pool;
const playerInternalBaseUrl = String(deps.playerInternalBaseUrl || '').replace(/\/$/, '');
const requirePermission = deps.requirePermission;
app.get('/pairing', requirePermission('pairing.allow'), async function (req, res, next) {
try {
const [screens] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, id ASC');
res.send(renderView('onboarding/pair', {
title: 'Pair player',
active: 'pairing',
currentUser: req.currentUser,
clientId: String(req.query.clientId || '').trim(),
pairingCode: String(req.query.code || '').trim(),
screens: screens,
scripts: ['vendor/jsqr/jsQR.js', 'js/onboarding/pair.js?v=' + encodeURIComponent(require('#root/package.json').version)]
}));
} catch (error) {
next(error);
}
});
app.post('/pairing', requirePermission('pairing.allow'), async function (req, res) {
const pairingCode = String(req.body && req.body.pairingCode || '').trim();
const clientId = String(req.body && req.body.clientId || '').trim();
const clientName = String(req.body && req.body.clientName || '').trim();
const screenSlug = String(req.body && req.body.screenSlug || '').trim();
if (!pairingCode || !clientName || !screenSlug) {
return res.status(400).json({ error: 'All pairing fields are required.' });
}
try {
let targetBaseUrl = playerInternalBaseUrl;
let deviceId = '';
let resolvedClientId = clientId;
{
const resolvePath = '/api/onboarding/resolve?pairingCode=' + encodeURIComponent(pairingCode);
const resolveAuthHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const resolveResponse = await fetch(`${targetBaseUrl}${resolvePath}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, resolveAuthHeaders)
});
const resolveBody = await resolveResponse.text();
let resolved = null;
try { resolved = JSON.parse(resolveBody); } catch (_error) {}
if (!resolveResponse.ok || !resolved || !resolved.deviceId) {
const registrations = await fetchPlayerRegistrations(pool);
const remoteRegistrations = registrations.filter(function (player) {
const internalUrl = String(player && player.internal_base_url || '').replace(/\/$/, '');
return internalUrl && internalUrl !== playerInternalBaseUrl;
});
if (!remoteRegistrations.length) {
return res.status(resolveResponse.status || 401).type(resolveResponse.headers.get('content-type') || 'application/json').send(resolveBody || JSON.stringify({ error: 'Unable to resolve kiosk pairing code.' }));
}
for (const remoteRegistration of remoteRegistrations) {
targetBaseUrl = String(remoteRegistration.internal_base_url).replace(/\/$/, '');
const bridgeResolveHeaders = createRequestAuthHeaders({ method: 'GET', pathname: '/api/onboarding/resolve' });
const bridgeResolveResponse = await fetch(`${targetBaseUrl}/api/onboarding/resolve?pairingCode=${encodeURIComponent(pairingCode)}`, {
method: 'GET',
headers: Object.assign({ Accept: 'application/json' }, bridgeResolveHeaders)
});
const bridgeResolveBody = await bridgeResolveResponse.text();
try { resolved = JSON.parse(bridgeResolveBody); } catch (_error) { resolved = null; }
if (bridgeResolveResponse.ok && resolved && resolved.deviceId) {
break;
}
}
if (!resolved || !resolved.deviceId) {
return res.status(401).json({ error: 'A valid kiosk pairing code is required.' });
}
}
if (resolved && resolved.deviceId) {
deviceId = String(resolved.deviceId).trim();
resolvedClientId = String(resolved.clientId || resolvedClientId || '').trim();
}
}
if (!resolvedClientId) {
return res.status(400).json({ error: 'Client ID is required.' });
}
const payload = { clientId: resolvedClientId, pairingCode: pairingCode, clientName: clientName, screenSlug: screenSlug };
const authHeaders = createRequestAuthHeaders({ method: 'POST', pathname: '/api/onboarding', body: payload });
const response = await fetch(`${targetBaseUrl}/api/onboarding`, {
method: 'POST',
headers: Object.assign({ 'Content-Type': 'application/json', Accept: 'application/json' }, authHeaders),
body: JSON.stringify(payload)
});
const responseBody = await response.text();
res.status(response.status).type(response.headers.get('content-type') || 'application/json').send(responseBody);
} catch (error) {
res.status(502).json({ error: error && error.message ? error.message : 'Player unavailable.' });
}
});
};
+6
View File
@@ -21,11 +21,17 @@ const registerAuditLogRoutes = require('./settings/audit-log');
const registerAboutRoutes = require('./settings/about/routes');
const registerInternalSyncRoutes = require('./internal/sync');
const registerScreensRoutes = require('./signage/screens/routes');
const registerOnboardingRoutes = require('./onboarding');
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
function registerRoutes(app, deps) {
// Group routes by the dependency bundle they need.
registerAuthAndAccountRoutes(app, deps);
registerOnboardingRoutes(app, {
pool: deps.pool,
playerInternalBaseUrl: deps.playerInternalBaseUrl,
requirePermission: deps.requirePermission
});
registerSignageRoutes(app, deps);
registerSettingsAndContentRoutes(app, deps);
registerSettingsPageRoutes(app, {
+18 -1
View File
@@ -32,6 +32,21 @@ module.exports = function registerClientsRoutes(app, deps) {
return compareSortValues(getComparableSortValue(leftValue), getComparableSortValue(rightValue));
}
function compareClientNames(leftValue, rightValue) {
const leftName = String(leftValue || '').trim();
const rightName = String(rightValue || '').trim();
if (!leftName && !rightName) {
return 0;
}
if (!leftName) {
return 1;
}
if (!rightName) {
return -1;
}
return leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
}
const sortKeys = accessors[normalizedSortKey]
? [normalizedSortKey]
: ['client'];
@@ -39,7 +54,9 @@ module.exports = function registerClientsRoutes(app, deps) {
return (Array.isArray(clients) ? clients.slice() : []).sort(function (leftClient, rightClient) {
for (let index = 0; index < sortKeys.length; index += 1) {
const sortKeyName = sortKeys[index];
const comparison = compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
const comparison = sortKeyName === 'client'
? compareClientNames(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient))
: compareValues(accessors[sortKeyName](leftClient), accessors[sortKeyName](rightClient));
if (comparison !== 0) {
return index === 0 && normalizedDirection === 'desc' ? comparison * -1 : comparison;
-11
View File
@@ -2,17 +2,7 @@
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',
@@ -25,7 +15,6 @@ module.exports = function renderDashboardPage(data, message, currentUser) {
slides: data.slides || [],
connectedClientsCount: Number(data.connectedClientsCount || 0),
connectedPlayersCount: Number(data.connectedPlayersCount || data.connectedClientsCount || 0),
primaryPlayerUrl: normalizeBaseUrl(primaryPlayerUrl) || null,
scripts: ['js/dashboard/dashboard-page.js']
});
};
@@ -26,6 +26,7 @@ function buildDuplicateTemplate(template, duplicateName) {
canvas_size_height: source.canvas_size_height,
background_color: source.background_color,
background_image_path: source.background_image_path,
background_gradient: source.background_gradient,
region_usage: [],
regions: Array.isArray(source.regions) ? source.regions.map(cloneRegion) : []
};
@@ -138,7 +138,7 @@
</div>
</div>
<div class="card-footer d-flex justify-content-end align-items-center">
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "api-sources.update")}}<button type="submit" form="api-source-toggle-form" data-async-data-source-toggle data-enabled="{{#if apiSource.enabled}}true{{else}}false{{/if}}" class="btn {{#if apiSource.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if apiSource.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if apiSource.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "api-sources.allow")}}<button type="button" data-manual-refresh-url="/data-sources/api-sources/{{apiSource.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</div>
@@ -39,7 +39,7 @@
</div>
</div>
<div class="card-footer d-flex justify-content-end align-items-center">
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
<div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "rss-feeds.update")}}<button type="submit" form="rss-feed-toggle-form" data-async-data-source-toggle data-enabled="{{#if rssFeed.enabled}}true{{else}}false{{/if}}" class="btn {{#if rssFeed.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if rssFeed.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if rssFeed.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "rss-feeds.allow")}}<button type="button" data-manual-refresh-url="/data-sources/rss-feeds/{{rssFeed.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
</div>
</div>
@@ -1,8 +1,8 @@
<div class="card card-outline card-secondary mt-3">
<div class="card-header d-flex align-items-center flex-wrap"><h3 class="card-title mb-0 flex-grow-1">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0 ms-auto" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary" data-weather-forecast-mode="daily" aria-pressed="true">Daily</button><button type="button" class="btn btn-outline-secondary" data-weather-forecast-mode="hourly" aria-pressed="false">24 hours</button></div></div>
<div class="card-body">
<div class="small text-body-secondary mb-3">Daily and hourly forecasts will appear here after the first successful fetch.</div>
{{#unless weatherPreview.hasSnapshot}}<div class="small text-body-secondary mb-3">Daily and hourly forecasts will appear here after the first successful fetch.</div>{{/unless}}
<div id="weather-daily-forecast" class="weather-daily-forecast">{{#each weatherPreview.forecast}}<div class="border rounded p-2 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div>{{/each}}</div>
<div id="weather-hourly-forecast" class="border-top mt-4 pt-3 d-none"><div class="small text-body-secondary mb-2">Hourly forecast · next 24 hours</div><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
<div id="weather-hourly-forecast"><div class="small text-body-secondary mb-2">Hourly forecast · next 24 hours</div><div class="d-flex gap-2 overflow-auto pb-2">{{#each weatherPreview.hourly}}<div class="border rounded p-2 text-center flex-shrink-0 weather-preview-hourly-item"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-preview-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>{{/each}}</div></div>
</div>
</div>
+1 -1
View File
@@ -15,7 +15,7 @@
<div class="row g-3"><div class="col-md-3"><label for="weather-provider" class="form-label">Provider</label><select id="weather-provider" name="provider" class="form-select"><option value="open-meteo" {{#if (eq weatherLocation.provider 'open-meteo')}}selected{{/if}}>Open-Meteo</option><option value="pirate-weather" {{#if (eq weatherLocation.provider 'pirate-weather')}}selected{{/if}} {{#unless providerAvailability.pirateWeather}}disabled{{/unless}}>Pirate Weather{{#unless providerAvailability.pirateWeather}} (API key not configured){{/unless}}</option></select></div><div class="col-md-3"><label for="weather-temperature-unit" class="form-label">Temperature</label><select id="weather-temperature-unit" name="temperature_unit" class="form-select"><option value="celsius" {{#if (eq weatherLocation.temperature_unit 'celsius')}}selected{{/if}}>Celsius</option><option value="fahrenheit" {{#if (eq weatherLocation.temperature_unit 'fahrenheit')}}selected{{/if}}>Fahrenheit</option></select></div><div class="col-md-3"><label for="weather-wind-unit" class="form-label">Wind</label><select id="weather-wind-unit" name="wind_unit" class="form-select"><option value="kmh" {{#if (eq weatherLocation.wind_unit 'kmh')}}selected{{/if}}>km/h</option><option value="mph" {{#if (eq weatherLocation.wind_unit 'mph')}}selected{{/if}}>mph</option><option value="ms" {{#if (eq weatherLocation.wind_unit 'ms')}}selected{{/if}}>m/s</option></select></div><div class="col-md-3"><label for="weather-precipitation-unit" class="form-label">Precipitation</label><select id="weather-precipitation-unit" name="precipitation_unit" class="form-select"><option value="mm" {{#if (eq weatherLocation.precipitation_unit 'mm')}}selected{{/if}}>Millimetres</option><option value="inch" {{#if (eq weatherLocation.precipitation_unit 'inch')}}selected{{/if}}>Inches</option></select></div></div>
<div class="row g-3 mt-1"><div class="col-md-6"><label for="weather-interval" class="form-label">Update interval</label><input id="weather-interval" name="update_interval_value" type="number" min="1" max="1440" class="form-control" value="{{weatherLocation.update_interval_value}}" required /></div><div class="col-md-6"><label for="weather-interval-unit" class="form-label">Unit</label><select id="weather-interval-unit" name="update_interval_unit" class="form-select"><option value="minutes" {{#if (eq weatherLocation.update_interval_unit 'minutes')}}selected{{/if}}>Minutes</option><option value="hours" {{#if (eq weatherLocation.update_interval_unit 'hours')}}selected{{/if}}>Hours</option></select></div></div>
</div>
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
<div class="card-footer d-flex justify-content-end align-items-center"><div class="me-auto">{{#if isEdit}}{{#if (hasPermission currentUser "weather.update")}}<button type="submit" form="weather-location-toggle-form" data-async-data-source-toggle data-enabled="{{#if weatherLocation.enabled}}true{{else}}false{{/if}}" class="btn {{#if weatherLocation.enabled}}btn-danger{{else}}btn-success{{/if}}"><i class="bi {{#if weatherLocation.enabled}}bi-pause-fill{{else}}bi-play-fill{{/if}} me-1" aria-hidden="true"></i>{{#if weatherLocation.enabled}}Disable{{else}}Enable{{/if}}</button>{{/if}} {{#if (hasPermission currentUser "weather.allow")}}<button type="button" data-weather-refresh-url="/data-sources/weather/{{weatherLocation.id}}/refresh" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1" aria-hidden="true"></i>Refresh now</button>{{/if}}{{/if}}</div>{{{saveActionButtons formId="weather-location-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}</div>
</div>
</form>
{{#if isEdit}}<form id="weather-location-toggle-form" method="post" action="/data-sources/weather/{{weatherLocation.id}}" data-async-command><input type="hidden" name="data_source_action" value="toggle" /></form>{{/if}}
+2 -1
View File
@@ -26,7 +26,8 @@
<td data-label="Name">{{name}}</td><td data-label="Location">{{location_label}}<br><small class="text-muted">{{latitude}}, {{longitude}} · {{timezone}}</small></td><td data-label="Provider">{{provider}}</td><td data-label="Refresh interval">{{intervalLabel}}</td><td data-label="Status"><span class="badge text-bg-{{#if enabled}}success{{else}}danger{{/if}}">{{#if enabled}}Enabled{{else}}Disabled{{/if}}</span></td>
<td data-label="Actions"><div class="actions">
{{#if (hasPermission ../currentUser 'weather.update')}}<a class="btn btn-sm btn-primary" href="/data-sources/weather/{{id}}/edit">Edit</a>{{/if}}
{{#if (hasPermission ../currentUser 'weather.delete')}}<form class="inline-form" method="post" action="/data-sources/weather/{{id}}/delete" data-confirm-message="Delete this weather location?"><button class="btn btn-sm btn-danger" type="submit">Delete</button></form>{{/if}}
{{#if (hasPermission ../currentUser 'weather.create')}}<a class="btn btn-sm btn-secondary" href="/data-sources/weather/{{id}}/duplicate">Dupe</a>{{/if}}
{{#if (hasPermission ../currentUser 'weather.delete')}}<form class="inline-form" method="post" action="/data-sources/weather/{{id}}/delete" data-confirm-message="Delete this weather location?">{{#if inUse}}<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>{{else}}<button class="btn btn-sm btn-danger" type="submit">Delete</button>{{/if}}</form>{{/if}}
</div></td>
</tr>
{{/each}}
-117
View File
@@ -1,117 +0,0 @@
<div class="page-header">
<div>
<h2>Weather source</h2>
<p>Configure a remote weather feed once, then serve its cached snapshot to every player.</p>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-xl-7">
<div class="card card-outline card-primary h-100 weather-mock-card">
<div class="card-header"><h3 class="card-title">Source configuration</h3></div>
<div class="card-body weather-mock-card-body">
<div class="row g-3">
<div class="col-12">
<label class="form-label" for="weather-name">Name</label>
<input id="weather-name" class="form-control" value="{{weather.name}}" />
</div>
<div class="col-12 col-md-8">
<label class="form-label" for="weather-location">Location</label>
<input id="weather-location" class="form-control" value="{{weather.location}}" />
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="weather-provider">Provider</label>
<select id="weather-provider" class="form-select">
<option selected>{{weather.provider}} (no key)</option>
<option>Custom JSON endpoint</option>
</select>
</div>
<div class="col-12"><div class="form-text"><i class="bi bi-info-circle me-1"></i>Location is the display name. Coordinates identify the weather point and can be set from a map picker, browser location, or manual entry.</div></div>
<div class="col-6">
<label class="form-label" for="weather-latitude">Latitude</label>
<input id="weather-latitude" class="form-control" value="{{weather.latitude}}" />
</div>
<div class="col-6">
<label class="form-label" for="weather-longitude">Longitude</label>
<input id="weather-longitude" class="form-control" value="{{weather.longitude}}" />
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="weather-temperature-unit">Temperature</label>
<select id="weather-temperature-unit" class="form-select"><option selected>{{weather.temperatureUnit}}</option><option>Fahrenheit (°F)</option></select>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="weather-wind-unit">Wind speed</label>
<select id="weather-wind-unit" class="form-select"><option selected>{{weather.windUnit}}</option><option>Miles per hour (mph)</option><option>Metres per second (m/s)</option></select>
</div>
<div class="col-12 col-md-4">
<label class="form-label" for="weather-precipitation-unit">Precipitation</label>
<select id="weather-precipitation-unit" class="form-select"><option selected>{{weather.precipitationUnit}}</option><option>Inches (in)</option></select>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="weather-refresh">Refresh interval</label>
<select id="weather-refresh" class="form-select"><option>15 minutes</option><option selected>{{weather.refreshInterval}}</option><option>1 hour</option></select>
</div>
<div class="col-12 col-md-6">
<label class="form-label" for="weather-forecast-mode">Forecast display</label>
<select id="weather-forecast-mode" class="form-select"><option selected value="daily">Daily forecast</option><option value="hourly">Hourly forecast (next 24 hours)</option></select>
<div class="form-text">The source can cache both datasets; this controls what the region displays.</div>
</div>
</div>
</div>
<div class="card-footer weather-mock-card-footer d-flex justify-content-between align-items-center">
<button type="button" class="btn btn-outline-secondary"><i class="bi bi-arrow-clockwise me-1"></i>Refresh now</button>
<button type="button" class="btn btn-primary"><i class="bi bi-check2 me-1"></i>Save source</button>
</div>
</div>
</div>
<div class="col-12 col-xl-5">
<div class="card card-outline card-secondary h-100 weather-mock-card">
<div class="card-header d-flex align-items-center gap-2"><h3 class="card-title mb-0 flex-grow-1">Cached snapshot</h3><span class="badge text-bg-success flex-shrink-0"><i class="bi bi-check-circle me-1"></i>Last fetch succeeded</span></div>
<div class="card-body weather-mock-card-body">
<div class="d-flex align-items-start justify-content-between border-bottom pb-3 mb-3">
<div><div class="text-body-secondary small">{{weather.location}}</div><div class="display-4 fw-semibold">{{weather.temperature}}°</div><div class="fw-medium">{{weather.condition}}</div></div>
<i class="bi bi-cloud-sun weather-mock-icon" aria-hidden="true"></i>
</div>
<div class="row g-3 small">
<div class="col-6"><span class="text-body-secondary d-block">Feels like</span><strong>{{weather.feelsLike}}°</strong></div>
<div class="col-6"><span class="text-body-secondary d-block">Humidity</span><strong>{{weather.humidity}}</strong></div>
<div class="col-6"><span class="text-body-secondary d-block">Wind</span><strong>{{weather.wind}}</strong></div>
<div class="col-6"><span class="text-body-secondary d-block">UV index</span><strong>{{weather.uvIndex}}</strong></div>
<div class="col-6"><span class="text-body-secondary d-block">Sunrise</span><strong>{{weather.sunrise}}</strong></div>
<div class="col-6"><span class="text-body-secondary d-block">Sunset</span><strong>{{weather.sunset}}</strong></div>
</div>
<div class="alert alert-light border mt-4 mb-0 small"><i class="bi bi-database-check me-1"></i> Cached {{weather.cacheAge}}. Players keep using this snapshot if the next request fails.</div>
</div>
<div class="card-footer weather-mock-card-footer small text-body-secondary">Last successful fetch: {{weather.fetchedAt}}</div>
</div>
</div>
</div>
<div class="card card-outline card-secondary mt-3">
<div class="card-header d-flex align-items-center justify-content-between gap-2 flex-wrap"><h3 class="card-title mb-0">Forecast preview</h3><div class="btn-group btn-group-sm flex-shrink-0" role="group" aria-label="Forecast preview mode"><button type="button" class="btn btn-primary">Daily</button><button type="button" class="btn btn-outline-secondary">24 hours</button></div></div>
<div class="card-body">
<div class="small text-body-secondary mb-3">{{weather.forecastModeLabel}} · timezone {{weather.timezone}}</div>
<div class="row row-cols-2 row-cols-md-4 g-2">
{{#each weather.forecast}}
<div class="col"><div class="border rounded p-3 h-100 text-center"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><div class="small mb-2">{{condition}}</div><strong>{{high}}°</strong><span class="text-body-secondary ms-2">{{low}}°</span><div class="small text-body-secondary mt-2"><i class="bi bi-droplet me-1"></i>{{rain}} rain</div></div></div>
{{/each}}
</div>
<div class="border-top mt-4 pt-3">
<div class="small text-body-secondary mb-2">Hourly option preview · next 24 hours</div>
<div class="d-flex gap-2 overflow-auto pb-2">
{{#each weather.hourly}}
<div class="border rounded p-2 text-center flex-shrink-0" style="width:7.5rem"><div class="small fw-semibold">{{day}}</div><i class="bi {{icon}} weather-mock-forecast-icon" aria-hidden="true"></i><strong>{{temperature}}°</strong><div class="small text-body-secondary mt-1"><i class="bi bi-droplet me-1"></i>{{rain}}</div></div>
{{/each}}
</div>
</div>
</div>
</div>
<style>
.weather-mock-card { display: flex; flex-direction: column; }
.weather-mock-card-body { flex: 1 1 auto; }
.weather-mock-card-footer { min-height: 3.5rem; }
.weather-mock-icon { font-size: 4rem; color: #e0a11a; }
.weather-mock-forecast-icon { display: block; font-size: 2rem; color: #e0a11a; margin: 1rem 0 .65rem; }
</style>
+72
View File
@@ -0,0 +1,72 @@
<div class="page-header">
<div>
<h2>Connect a screen</h2>
<p>Pair a player with a screen using its six-character PIN.</p>
</div>
</div>
<div class="row justify-content-center">
<div class="col-12 col-lg-7 col-xl-6">
<div class="card card-outline card-primary admin-form-card onboarding-pairing-card" id="onboarding-pair-card">
<div class="card-header">
<h3 class="card-title">Screen setup</h3>
</div>
<form id="onboarding-pair-form" method="post" action="/pairing" novalidate>
<div class="card-body">
<div class="onboarding-pairing-progress d-none" id="onboarding-pair-progress" role="status" aria-live="polite">
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span>
<span>Completing pairing...</span>
</div>
<div id="onboarding-pair-message" class="alert d-none" role="alert"></div>
<input type="hidden" id="onboarding-pair-client-id" name="clientId" value="{{clientId}}">
<div class="mb-3">
<label class="form-label" for="onboarding-pair-code-1">Player PIN</label>
<div class="onboarding-pin-inputs" id="onboarding-pair-code-inputs" data-pairing-code="{{pairingCode}}" role="group" aria-label="Player PIN" aria-required="true">
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-1" data-pairing-code-input type="text" maxlength="1" inputmode="text" autocomplete="one-time-code" aria-label="Player PIN character 1">
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-2" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 2">
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-3" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 3">
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-4" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 4">
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-5" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 5">
<input class="form-control form-control-lg text-uppercase font-weight-bold" id="onboarding-pair-code-6" data-pairing-code-input type="text" maxlength="1" inputmode="text" aria-label="Player PIN character 6">
</div>
<input id="onboarding-pair-code" type="hidden" name="pairingCode">
</div>
<div class="mb-4">
<label class="form-label" for="onboarding-pair-name">Client name</label>
<input class="form-control" id="onboarding-pair-name" name="clientName" maxlength="255" required placeholder="Lobby player" autocomplete="off">
</div>
<div class="mb-2">
<label class="form-label" for="onboarding-pair-screen">Screen</label>
<select class="form-select" id="onboarding-pair-screen" name="screenSlug" required>
<option value="">Select a screen</option>
{{#each screens}}
<option value="{{slug}}">{{name}}</option>
{{/each}}
</select>
</div>
</div>
<div class="card-footer d-flex flex-wrap align-items-center">
<button class="btn btn-outline-secondary" id="onboarding-pair-another" type="button" aria-label="Scan player QR code" title="Scan player QR code">
<i class="bi bi-camera" aria-hidden="true"></i>
<span class="d-none" id="onboarding-pair-another-label">Pair another</span>
</button>
<button class="btn btn-secondary d-none ms-auto" id="onboarding-pair-manual" type="button">Enter PIN manually</button>
<button class="btn btn-primary ms-auto" id="onboarding-pair-connect" type="submit">Connect</button>
</div>
</form>
</div>
</div>
</div>
<div class="onboarding-scanner d-none" id="onboarding-scanner" aria-hidden="true">
<div class="onboarding-scanner-panel" role="dialog" aria-modal="true" aria-labelledby="onboarding-scanner-title">
<div class="d-flex align-items-center justify-content-between gap-3 mb-3">
<h2 class="h5 mb-0" id="onboarding-scanner-title">Scan player QR code</h2>
<button class="btn btn-sm btn-outline-secondary" id="onboarding-scanner-close" type="button">Close</button>
</div>
<video id="onboarding-scanner-video" class="onboarding-scanner-video" autoplay playsinline></video>
<input id="onboarding-scanner-capture" type="file" accept="image/*" capture="environment" class="d-none">
<p class="text-body-secondary small mb-0" id="onboarding-scanner-message">Point your camera at the player QR code.</p>
<pre class="d-none small mt-2 mb-0" id="onboarding-scanner-debug" aria-live="polite"></pre>
</div>
</div>
+27 -16
View File
@@ -5,7 +5,7 @@
</div>
</div>
{{#if (hasPermission currentUser 'clients.allow')}}
{{#if (anyPermission currentUser 'clients.allow' 'pairing.allow')}}
<div class="card card-outline card-secondary mb-4 screen-command-card">
<div class="card-header">
<div class="dashboard-card-heading">
@@ -13,9 +13,10 @@
</div>
</div>
<div class="card-body">
{{#if screens.length}}
<div class="screen-command-panel">
<div class="screen-command-panel-left">
<div class="screen-command-panel{{#if (hasPermission currentUser 'clients.allow')}}{{else}} screen-command-panel-pairing-only{{/if}}">
{{#if (hasPermission currentUser 'clients.allow')}}
{{#if screens.length}}
<div class="screen-command-panel-left">
<div class="screen-command-selector-field">
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
<div class="input-group input-group-sm">
@@ -55,18 +56,28 @@
<button type="submit" class="btn btn-sm btn-secondary" disabled><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout screen</button>
</form>
</div>
</div>
<div class="screen-command-panel-right">
<div class="screen-command-summary">
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
<strong data-screen-command-name>Select a target screen group</strong>
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
</div>
<div class="screen-command-panel-right">
<div class="screen-command-summary">
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
<strong data-screen-command-name>Select a target screen group</strong>
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
</div>
</div>
{{else}}
<div class="empty">No screen groups are available to target yet.</div>
{{/if}}
{{/if}}
{{#if (hasPermission currentUser 'pairing.allow')}}
<div class="screen-command-pairing">
<div>
<strong>New player?</strong>
<span class="screen-command-summary-meta">Open the pairing page to connect another display.</span>
</div>
<a class="btn btn-primary btn-sm" href="/pairing"><i class="bi bi-qr-code-scan me-1" aria-hidden="true"></i>Pair a player</a>
</div>
</div>
{{else}}
<div class="empty">No screen groups are available to target yet.</div>
{{/if}}
{{/if}}
</div>
</div>
</div>
{{/if}}
@@ -96,7 +107,7 @@
<tbody id="dashboard-clients-table-body">
{{#if clients.length}}
{{#each clients}}
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}">
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{id}}" data-client-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}" data-client-player-base-url="{{player_url}}">
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
</td>
@@ -186,7 +197,7 @@
<div class="modal-body">
<input type="hidden" name="command" value="moveclient" />
<input type="hidden" name="connectionId" value="" data-client-move-connection-id />
<input type="hidden" name="deviceId" value="" data-client-move-device-id />
<input type="hidden" name="clientId" value="" data-client-move-client-id />
<input type="hidden" name="clientName" value="" data-client-move-client-name />
<input type="hidden" name="playerBaseUrl" value="" data-client-move-player-base-url />
<label class="form-label" for="client-move-screen-target">Target screen group</label>
+3 -8
View File
@@ -11,13 +11,6 @@
<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 group 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 display to get started.
</div>
{{/if}}
</div>
<div class="dashboard-hero-stats">
{{#if (hasPermission currentUser "playlists.read")}}
@@ -194,7 +187,9 @@
<h3 class="card-title">Screen group snapshot</h3>
<p class="dashboard-card-subtitle">Playlist assignment and live connection state without the spreadsheet feel.</p>
</div>
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
{{#if (hasPermission currentUser "pairing.allow")}}
<a class="btn btn btn-primary" href="/pairing"><i class="bi bi-qr-code-scan me-1" aria-hidden="true"></i>Pair a player</a>
{{/if}}
</div>
<div class="card-body">
<div id="dashboard-screens-grid" class="dashboard-screen-grid">
+16 -2
View File
@@ -180,13 +180,27 @@
var backgroundColor = String(payload.backgroundColor || '#111111').trim() || '#111111';
var backgroundImagePath = String(payload.backgroundImagePath || '').trim();
var backgroundGradient = '';
try {
var gradientData = typeof payload.backgroundGradient === 'string' ? JSON.parse(payload.backgroundGradient) : payload.backgroundGradient;
if (gradientData && gradientData.type === 'linear' && Array.isArray(gradientData.colors) && gradientData.colors.length >= 2) {
var gradientStops = Array.isArray(gradientData.stops) ? gradientData.stops : (gradientData.colors || []).map(function (color, index, colors) { return { color: color, position: colors.length > 1 ? Math.round(index * 100 / (colors.length - 1)) : 0 }; });
gradientStops = gradientStops.filter(function (stop) { return stop && /^#[0-9a-fA-F]{3,8}$/.test(String(stop.color || '').trim()); }).slice(0, 12);
if (gradientStops.length >= 2) {
var gradientAngle = Number(gradientData.angle);
backgroundGradient = 'linear-gradient(' + (Number.isFinite(gradientAngle) ? Math.max(0, Math.min(360, gradientAngle)) : 90) + 'deg,' + gradientStops.map(function (stop) { return stop.color + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; }).join(',') + ')';
}
}
} catch (_error) {
backgroundGradient = '';
}
var canvasWidth = Math.max(1, Number(payload.canvasWidth || 1920));
var canvasHeight = Math.max(1, Number(payload.canvasHeight || 1080));
canvas.style.backgroundColor = backgroundColor;
canvas.style.backgroundImage = backgroundImagePath ? 'url("' + encodeURI(backgroundImagePath) + '")' : 'none';
canvas.style.backgroundImage = [backgroundImagePath ? 'url("' + encodeURI(backgroundImagePath) + '")' : '', backgroundGradient].filter(Boolean).join(',') || 'none';
canvas.style.backgroundPosition = 'center';
canvas.style.backgroundSize = '100% 100%';
canvas.style.backgroundSize = backgroundImagePath ? 'contain, cover' : 'cover';
canvas.style.backgroundRepeat = 'no-repeat';
canvas.style.transformOrigin = 'center center';
canvas.style.width = canvasWidth + 'px';
+21
View File
@@ -110,6 +110,10 @@
<label for="background-color" class="form-label">Background colour</label>
<input type="color" name="background_color" id="background-color" class="form-control form-control-color w-100" value="{{#if template.background_color}}{{template.background_color}}{{else}}#111111{{/if}}" title="Choose a background colour" />
</div>
<div class="col-12">
<input type="hidden" name="background_gradient" id="background-gradient" value="{{template.background_gradient}}" />
<button type="button" class="btn btn-outline-secondary" data-bs-toggle="modal" data-bs-target="#background-advanced-modal"><i class="bi bi-sliders me-1" aria-hidden="true"></i>Advanced background</button>
</div>
</div>
</div>
</div>
@@ -117,6 +121,23 @@
</div>
</form>
{{#> modal-shell modalId="background-advanced-modal" modalLabelId="background-advanced-modal-label" modalDialogClass="modal-dialog-centered"}}
<div class="modal-header">
<h2 class="modal-title fs-5" id="background-advanced-modal-label">Advanced background</h2>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" id="background-gradient-enabled" {{#if template.background_gradient}}checked{{/if}} />
<label class="form-check-label" for="background-gradient-enabled">Use linear gradient</label>
</div>
<div class="row g-3" id="background-gradient-options" {{#if template.background_gradient}}{{else}}hidden{{/if}}>
<div class="col-12"><label for="background-gradient-angle" class="form-label">Gradient angle <output id="background-gradient-angle-output">90</output>&deg;</label><input type="range" id="background-gradient-angle" class="form-range" min="0" max="360" value="90" /></div>
<div class="col-12"><div class="gradient-stop-bar mb-3" id="background-gradient-bar" role="slider" aria-label="Gradient colour stops" aria-valuemin="0" aria-valuemax="100"><div class="gradient-stop-bar-track"></div><div class="gradient-stop-bar-handles" id="background-gradient-bar-handles"></div></div><div class="d-flex justify-content-between align-items-center mb-2"><strong>Colour stops</strong><button type="button" class="btn btn-sm btn-outline-secondary" id="background-gradient-add-stop"><i class="bi bi-plus-lg me-1" aria-hidden="true"></i>Add stop</button></div><div class="d-grid gap-2" id="background-gradient-stops"></div></div>
</div>
</div>
{{/modal-shell}}
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
<script src="/assets/vendor/qr-code-styling/qr-code-styling-loader.js?v={{appVersion}}"></script>