Add onboarding weather and template gradients
This commit is contained in:
+2
-2
@@ -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
@@ -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
@@ -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
@@ -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)`
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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);
|
||||
});
|
||||
}());
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
|
||||
@@ -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%;
|
||||
|
||||
@@ -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();
|
||||
};
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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) {
|
||||
|
||||
@@ -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.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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>';
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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(); }
|
||||
});
|
||||
}
|
||||
}());
|
||||
@@ -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">×</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();
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Vendored
+10102
File diff suppressed because it is too large
Load Diff
@@ -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,
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
};
|
||||
@@ -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.' });
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -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, {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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}}
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>°</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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user