Compare commits

...
2 Commits
Author SHA1 Message Date
lzstealth 7973ee0ea4 Fix duplicate onboarding client names 2026-07-21 02:03:09 +01:00
lzstealth 6416dbfd99 Release v1.3.3 2026-07-21 01:55:10 +01:00
12 changed files with 230 additions and 89 deletions
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.2.0", "version": "1.3.3",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.2.0", "version": "1.3.3",
"dependencies": { "dependencies": {
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
"dotenv": "^17.4.2", "dotenv": "^17.4.2",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.3.2", "version": "1.3.4",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media uploads", "description": "Pulse Signage application with MySQL and media uploads",
"repository": { "repository": {
+43 -1
View File
@@ -1,3 +1,5 @@
const crypto = require('crypto');
function normalizeClientName(value) { function normalizeClientName(value) {
return String(value || '').trim(); return String(value || '').trim();
} }
@@ -68,9 +70,49 @@ async function isClientNameAvailable(pool, clientName, excludeDeviceId, liveConn
} }
} }
function buildClientNameLockName(clientName) {
return `ps_client_name_${crypto.createHash('sha1').update(String(clientName || '').trim().toLowerCase()).digest('hex')}`;
}
async function withClientNameReservation(pool, clientName, handler) {
if (!pool || typeof pool.getConnection !== 'function') {
return handler();
}
const normalizedName = normalizeClientName(clientName);
if (!normalizedName) {
return handler();
}
const connection = await pool.getConnection();
const lockName = buildClientNameLockName(normalizedName);
let lockAcquired = false;
try {
const [lockRows] = await connection.query('SELECT GET_LOCK(?, 5) AS lock_result', [lockName]);
const lockResult = lockRows && lockRows[0] ? Number(lockRows[0].lock_result) : 0;
if (lockResult !== 1) {
const error = new Error('Client name is busy. Please try again.');
error.statusCode = 409;
throw error;
}
lockAcquired = true;
return await handler();
} finally {
if (lockAcquired) {
try {
await connection.query('SELECT RELEASE_LOCK(?)', [lockName]);
} catch (_error) {}
}
connection.release();
}
}
module.exports = { module.exports = {
normalizeClientName: normalizeClientName, normalizeClientName: normalizeClientName,
normalizeDeviceId: normalizeDeviceId, normalizeDeviceId: normalizeDeviceId,
collectLiveConnections: collectLiveConnections, collectLiveConnections: collectLiveConnections,
isClientNameAvailable: isClientNameAvailable isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation
}; };
+19 -19
View File
@@ -1,4 +1,4 @@
const { isClientNameAvailable } = require('../client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('../client-name-check');
const { isTransientDbError } = require('./onboarding-store'); const { isTransientDbError } = require('./onboarding-store');
function normalizeDeviceId(value) { function normalizeDeviceId(value) {
return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128); return String(value || '').trim().replace(/[^a-zA-Z0-9_-]/g, '').slice(0, 128);
@@ -48,27 +48,27 @@ async function commitDeviceBinding(pool, deviceId, clientName, screenSlug, isNam
throw new Error('Screen is required.'); throw new Error('Screen is required.');
} }
const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]); return withClientNameReservation(pool, normalizedClientName, async function () {
if (!screenRows.length) { const [screenRows] = await pool.query('SELECT id, name, slug FROM screens WHERE slug = ?', [normalizedScreenSlug]);
throw new Error('Screen not found.'); if (!screenRows.length) {
} throw new Error('Screen not found.');
const screen = screenRows[0]; }
const screen = screenRows[0];
const available = typeof isClientNameAvailableOnScreen === 'function' const available = await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections);
? await isClientNameAvailable(pool, normalizedClientName, normalizedDeviceId, liveConnections) if (!available) {
: true; const error = new Error('Client name already exists.');
if (!available) { error.statusCode = 400;
const error = new Error('Client name already exists.'); throw error;
error.statusCode = 400; }
throw error;
}
await pool.query( await pool.query(
'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP', 'INSERT INTO player_onboarding_devices (device_id, client_name, screen_id) VALUES (?, ?, ?) ON DUPLICATE KEY UPDATE client_name = VALUES(client_name), screen_id = VALUES(screen_id), modified_at = CURRENT_TIMESTAMP',
[normalizedDeviceId, normalizedClientName, screen.id] [normalizedDeviceId, normalizedClientName, screen.id]
); );
return getOnboardingStatus(pool, normalizedDeviceId); return getOnboardingStatus(pool, normalizedDeviceId);
});
} }
async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) { async function bindDeviceToScreen(pool, deviceId, clientName, screenSlug, isNameAvailableOnScreen, playerRuntime, onboardingStore) {
+3 -2
View File
@@ -16,7 +16,7 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin-screen-comm
const registerAdminContentRoutes = require('./web/routes/admin-content'); const registerAdminContentRoutes = require('./web/routes/admin-content');
const { createWebBootstrap } = require('./web/bootstrap'); const { createWebBootstrap } = require('./web/bootstrap');
const { createPlayerActionService } = require('./web/player-actions'); const { createPlayerActionService } = require('./web/player-actions');
const { isClientNameAvailable } = require('./client-name-check'); const { isClientNameAvailable, withClientNameReservation } = require('./client-name-check');
const { createSessionService } = require('./web/session'); const { createSessionService } = require('./web/session');
const { const {
formatDashboardDate, formatDashboardDate,
@@ -179,7 +179,8 @@ async function start() {
pool: pool, pool: pool,
forwardPlayerCommand: playerActionService.forwardPlayerCommand, forwardPlayerCommand: playerActionService.forwardPlayerCommand,
getScreenConnections: playerActionService.getScreenConnections, getScreenConnections: playerActionService.getScreenConnections,
isClientNameAvailable: isClientNameAvailable isClientNameAvailable: isClientNameAvailable,
withClientNameReservation: withClientNameReservation
}); });
registerAdminContentRoutes(app, { registerAdminContentRoutes(app, {
+27
View File
@@ -123,10 +123,37 @@
--bs-navbar-nav-link-padding-x: 10px; --bs-navbar-nav-link-padding-x: 10px;
} }
.app-sidebar {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.sidebar-wrapper {
display: flex;
flex: 1 1 auto;
flex-direction: column;
min-height: 0;
overflow-y: auto;
}
.min-h-0 {
min-height: 0;
}
.sidebar-brand .brand-text.fw-light { .sidebar-brand .brand-text.fw-light {
margin: 0; margin: 0;
} }
.sidebar-version {
font-size: 0.8rem;
letter-spacing: 0.04em;
}
.sidebar-version__inner {
opacity: 0.8;
}
.card { .card {
box-shadow: none; box-shadow: none;
} }
@@ -82,14 +82,16 @@
function renderClientActionCell(client) { function renderClientActionCell(client) {
var paused = Boolean(client.paused); var paused = Boolean(client.paused);
var pauseButtonClass = 'btn btn-sm btn-info'; var pauseButtonClass = 'btn btn-sm btn-info';
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
var pauseButtonLabel = paused ? 'Resume' : 'Pause'; var pauseButtonLabel = paused ? 'Resume' : 'Pause';
var blackout = Boolean(client.blackout); var blackout = Boolean(client.blackout);
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary'); var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout'; var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?'; var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
var blackoutCommandValue = blackout ? 'false' : 'true'; var blackoutCommandValue = blackout ? 'false' : 'true';
return '<div class="d-flex flex-wrap gap-1"><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>'; return '<div class="d-flex flex-wrap gap-1"><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/admin/screens/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
} }
function updateClientActionCell(cell, client) { function updateClientActionCell(cell, client) {
@@ -105,7 +107,7 @@
var paused = Boolean(client.paused); var paused = Boolean(client.paused);
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info'); setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
pauseButton.innerHTML = '<i class="bi bi-pause-fill me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause'); pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
var pauseForm = pauseButton.form; var pauseForm = pauseButton.form;
if (pauseForm) { if (pauseForm) {
@@ -142,8 +144,9 @@
} }
var blackout = Boolean(client.blackout); var blackout = Boolean(client.blackout);
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary'); setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
blackoutButton.innerHTML = '<i class="bi bi-eye-slash me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout'); blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
var blackoutForm = blackoutButton.form; var blackoutForm = blackoutButton.form;
if (blackoutForm) { if (blackoutForm) {
@@ -357,8 +360,9 @@
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients'; var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
var blackoutForm = blackoutButton.form; var blackoutForm = blackoutButton.form;
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null; var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
var blackoutButtonIcon = allBlackout ? 'bi-eye' : 'bi-eye-slash';
blackoutButton.innerHTML = (allBlackout ? '<i class="bi bi-eye me-1" aria-hidden="true"></i>' : '<i class="bi bi-eye-slash me-1" aria-hidden="true"></i>') + escapeHtml(label); blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(label);
setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary'); setButtonVariant(blackoutButton, ['btn-success', 'btn-danger', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], 'btn-secondary');
if (blackoutInput) { if (blackoutInput) {
blackoutInput.value = allBlackout ? 'false' : 'true'; blackoutInput.value = allBlackout ? 'false' : 'true';
+51
View File
@@ -20,6 +20,57 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommand = deps.forwardPlayerCommand;
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl; const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
async function fetchAllScreenSlugs() {
const [rows] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
return (rows || [])
.map(function (row) {
return String(row && row.slug ? row.slug : '').trim();
})
.filter(Boolean);
}
app.post('/admin/commands', async function (req, res, next) {
try {
const command = String((req.body && req.body.command) || req.query.command || '').trim().toLowerCase();
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
if (!command) {
return res.status(400).json({ error: 'Command is required' });
}
if (command !== 'reload' && command !== 'blackout') {
return res.status(400).json({ error: 'Unsupported command' });
}
const slugs = await fetchAllScreenSlugs();
if (!slugs.length) {
await broadcastDashboardState();
return res.json({ ok: true, command: command, sent: 0 });
}
const payload = command === 'blackout'
? {
command: 'blackout',
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
}
: 'reload';
const sentCount = await notifyPlayerScreens(slugs, payload);
await broadcastDashboardState();
return res.json({
ok: true,
command: command,
sent: sentCount,
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined
});
} catch (error) {
next(error);
}
});
app.post('/admin/playlists', async function (req, res, next) { app.post('/admin/playlists', async function (req, res, next) {
try { try {
const name = String(req.body.name || '').trim(); const name = String(req.body.name || '').trim();
+59 -54
View File
@@ -3,6 +3,7 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
const forwardPlayerCommand = deps.forwardPlayerCommand; const forwardPlayerCommand = deps.forwardPlayerCommand;
const getScreenConnections = deps.getScreenConnections; const getScreenConnections = deps.getScreenConnections;
const isClientNameAvailable = deps.isClientNameAvailable; const isClientNameAvailable = deps.isClientNameAvailable;
const withClientNameReservation = deps.withClientNameReservation;
app.post('/admin/screens/:slug/commands', async function (req, res, next) { app.post('/admin/screens/:slug/commands', async function (req, res, next) {
try { try {
@@ -56,40 +57,66 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
unchanged: true unchanged: true
}); });
} }
let liveConnections = []; if (typeof withClientNameReservation !== 'function') {
try { return res.status(500).json({ error: 'Client name reservation is unavailable.' });
const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
const screenSlug = String(row && row.slug ? row.slug : '').trim();
if (!screenSlug || typeof getScreenConnections !== 'function') {
return [];
}
try {
const liveResponse = await getScreenConnections(screenSlug);
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
} catch (_error) {
return [];
}
}));
liveConnections = liveResults.flat();
} catch (_error) {
liveConnections = [];
} }
const available = typeof isClientNameAvailable === 'function' return withClientNameReservation(pool, clientName, async function () {
? await isClientNameAvailable(pool, clientName, deviceId, liveConnections) let liveConnections = [];
: true; try {
if (!available) { const [screenSlugs] = await pool.query('SELECT slug FROM screens ORDER BY slug ASC');
return res.status(409).json({ error: 'Client name already exists.' }); const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
} const screenSlug = String(row && row.slug ? row.slug : '').trim();
if (!screenSlug || typeof getScreenConnections !== 'function') {
return [];
}
try {
const liveResponse = await getScreenConnections(screenSlug);
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
} catch (_error) {
return [];
}
}));
liveConnections = liveResults.flat();
} catch (_error) {
liveConnections = [];
}
if (!onboardingRow) { const available = await isClientNameAvailable(pool, clientName, deviceId, liveConnections);
await forwardPlayerCommand(slug, { if (!available) {
command: command, return res.status(409).json({ error: 'Client name already exists.' });
clientName: clientName, }
clientId: connectionId || deviceId || null,
deviceId: deviceId || null if (!onboardingRow) {
}, connectionId || deviceId || undefined); await forwardPlayerCommand(slug, {
command: command,
clientName: clientName,
clientId: connectionId || deviceId || null,
deviceId: deviceId || null
}, connectionId || deviceId || undefined);
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: clientName,
ok: true,
liveOnly: true
});
}
const [updateResult] = await pool.query(
`UPDATE player_onboarding_devices pod
JOIN 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]
);
if (!updateResult.affectedRows) {
return res.status(404).json({ error: 'Client not found' });
}
return res.json({ return res.json({
screen: screenRows[0], screen: screenRows[0],
@@ -98,30 +125,8 @@ module.exports = function registerAdminScreenCommandRoutes(app, deps) {
connectionId: connectionId || null, connectionId: connectionId || null,
deviceId: deviceId, deviceId: deviceId,
clientName: clientName, clientName: clientName,
ok: true, ok: true
liveOnly: true
}); });
}
const [updateResult] = await pool.query(
`UPDATE player_onboarding_devices pod
JOIN 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]
);
if (!updateResult.affectedRows) {
return res.status(404).json({ error: 'Client not found' });
}
return res.json({
screen: screenRows[0],
screenSlug: slug,
command: command,
connectionId: connectionId || null,
deviceId: deviceId,
clientName: clientName,
ok: true
}); });
} }
+6 -2
View File
@@ -67,12 +67,16 @@
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command> <form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="pause" /> <input type="hidden" name="command" value="pause" />
<input type="hidden" name="connectionId" value="{{id}}" /> <input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm btn-info">{{#if paused}}Resume{{else}}Pause{{/if}}</button> <button type="submit" class="btn btn-sm btn-info">
<i class="bi bi-pause-fill me-1" aria-hidden="false"></i>Pause
</button>
</form> </form>
<form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command> <form method="post" action="/admin/screens/{{screen_slug}}/commands" class="inline-form" data-async-command>
<input type="hidden" name="command" value="blackout" /> <input type="hidden" name="command" value="blackout" />
<input type="hidden" name="connectionId" value="{{id}}" /> <input type="hidden" name="connectionId" value="{{id}}" />
<button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}">{{#if blackout}}Restore{{else}}Blackout{{/if}}</button> <button type="submit" class="btn btn-sm {{#if blackout}}btn-success{{else}}btn-secondary{{/if}}">
<i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout
</button>
</form> </form>
</div> </div>
</td> </td>
+1 -1
View File
@@ -68,7 +68,7 @@
type="submit" type="submit"
class="btn btn-secondary dashboard-action-button" class="btn btn-secondary dashboard-action-button"
id="dashboard-blackout-all-button" id="dashboard-blackout-all-button"
><i class="bi bi-eye-slash me-1" aria-hidden="true"></i>Blackout all clients</button> ><i class="bi bi-eye-slash me-1" aria-hidden="false"></i>Blackout all clients</button>
<span class="dashboard-action-help">Blank active players immediately.</span> <span class="dashboard-action-help">Blank active players immediately.</span>
</form> </form>
</div> </div>
+10 -3
View File
@@ -6,6 +6,8 @@
<title>{{title}} - Pulse</title> <title>{{title}} - Pulse</title>
<link rel="icon" type="image/png" href="/assets/favicon.png" /> <link rel="icon" type="image/png" href="/assets/favicon.png" />
<script src="/assets/js/theme-init.js"></script> <script src="/assets/js/theme-init.js"></script>
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff2" as="font" type="font/woff2" crossorigin="anonymous" />
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff" as="font" type="font/woff" crossorigin="anonymous" />
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" /> <link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" /> <link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
<link rel="stylesheet" href="/assets/css/theme-custom.css" /> <link rel="stylesheet" href="/assets/css/theme-custom.css" />
@@ -126,14 +128,14 @@
</div> </div>
</nav> </nav>
<aside class="app-sidebar bg-body-secondary shadow" data-bs-theme="dark"> <aside class="app-sidebar bg-body-secondary shadow d-flex flex-column" data-bs-theme="dark">
<div class="sidebar-brand"> <div class="sidebar-brand">
<a href="/admin" class="brand-link"> <a href="/admin" class="brand-link">
<span class="brand-text fw-light">Pulse Signage</span> <span class="brand-text fw-light">Pulse Signage</span>
</a> </a>
</div> </div>
<div class="sidebar-wrapper"> <div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0">
<nav class="mt-2" aria-label="Main navigation"> <nav class="mt-2 flex-grow-1" aria-label="Main navigation">
<ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation"> <ul class="nav sidebar-menu flex-column" data-lte-toggle="treeview" data-accordion="false" role="menu" id="navigation">
<li class="nav-header">MAIN NAVIGATION</li> <li class="nav-header">MAIN NAVIGATION</li>
<li class="nav-item"> <li class="nav-item">
@@ -187,6 +189,11 @@
</li> </li>
</ul> </ul>
</nav> </nav>
<div class="sidebar-version mt-auto pt-2 text-secondary small">
<div class="sidebar-version__inner border-top pt-2">
v{{appVersion}}
</div>
</div>
</div> </div>
</aside> </aside>