Release v2.2.0

This commit is contained in:
2026-08-01 21:41:44 +01:00
parent c643d2fb07
commit d6417b667c
673 changed files with 146752 additions and 7389 deletions
@@ -0,0 +1,41 @@
// Announcement add page renderer and form defaults.
const { renderView } = require('../../../view');
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
function buildDefaultAnnouncement() {
return {
id: null,
message: '',
short_label: '',
announcement_type: 'lower-third',
color_key: 'primary',
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
durationMode: 'duration',
duration_seconds: null,
durationValue: 5,
durationUnit: 'minutes',
durationLabel: 'Until disabled',
screen_ids: []
};
}
module.exports = function renderAnnouncementFormPage(announcement, mode, message, currentUser, options) {
const isEdit = mode === 'edit';
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
return renderView(isEdit ? 'signage/announcements/edit' : 'signage/announcements/add', {
title: isEdit ? 'Edit announcement' : 'Add announcement',
active: 'announcements',
message: message,
currentUser: currentUser || null,
announcement: Object.assign({}, buildDefaultAnnouncement(), announcement || {}),
announcementTypes: announcementTypes,
announcementColors: announcementColors,
announcementIcons: announcementIcons,
announcementScreens: announcementScreens
});
};
@@ -0,0 +1,7 @@
// Announcement edit page renderer that reuses the add form.
const renderAnnouncementFormPage = require('./add');
module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) {
return renderAnnouncementFormPage(announcement, 'edit', message, currentUser, options);
};
@@ -0,0 +1,96 @@
// Announcement list page renderer.
const { renderView } = require('../../../view');
const { ANNOUNCEMENT_ICON_LABELS, DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
function formatDurationLabel(seconds) {
const totalSeconds = Math.max(1, Number(seconds) || 0);
if (totalSeconds % 60 === 0) {
const minutes = totalSeconds / 60;
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
}
return totalSeconds === 1 ? '1 second' : `${totalSeconds} seconds`;
}
function formatStatusLabel(announcement) {
if (announcement.expires_at && new Date(announcement.expires_at).getTime() <= Date.now()) {
return 'Expired';
}
return 'Active';
}
function formatActionButton(announcement) {
const isActive = formatStatusLabel(announcement) === 'Active';
return {
label: isActive ? 'Stop' : 'Play',
icon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
className: isActive ? 'btn-outline-warning' : 'btn-outline-success',
confirmMessage: isActive
? 'Stop this announcement on the selected screens now?'
: 'Send this announcement to the selected screens now?',
actionPath: isActive ? `/announcements/${announcement.id}/stop` : `/announcements/${announcement.id}/play`
};
}
function formatIconLabel(iconKey) {
const normalized = String(iconKey || '').trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON;
return ANNOUNCEMENT_ICON_LABELS[normalized] || normalized;
}
function formatExpiresLabel(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return date.toLocaleString();
}
function formatScreenTargetsLabel(announcement) {
const targetCount = Math.max(0, Number(announcement && announcement.screen_target_count) || 0);
const totalScreenCount = Math.max(0, Number(announcement && announcement.total_screen_count) || 0);
const screenNames = String(announcement && announcement.screen_targets_label || '').trim();
if (!targetCount) {
return 'No Screens';
}
if (totalScreenCount > 0 && targetCount >= totalScreenCount) {
return 'All Screens';
}
return screenNames || 'No Screens';
}
module.exports = function renderAnnouncementsPage(data, message, currentUser) {
const announcements = (data.announcements || []).map(function (announcement) {
const actionButton = formatActionButton(announcement);
return Object.assign({}, announcement, {
shortLabel: String(announcement.short_label || '').trim(),
iconKey: String(announcement.icon_key || DEFAULT_ANNOUNCEMENT_ICON).trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON,
iconLabel: formatIconLabel(announcement.icon_key),
durationLabel: announcement.duration_seconds ? formatDurationLabel(announcement.duration_seconds) : 'Until disabled',
expiresAtLabel: formatExpiresLabel(announcement.expires_at),
screenTargetsLabel: formatScreenTargetsLabel(announcement),
statusLabel: formatStatusLabel(announcement),
actionButtonLabel: actionButton.label,
actionButtonIcon: actionButton.icon,
actionButtonClassName: actionButton.className,
actionButtonConfirmMessage: actionButton.confirmMessage,
actionButtonPath: actionButton.actionPath
});
});
return renderView('signage/announcements/list', {
title: 'Announcements',
active: 'announcements',
message: message,
currentUser: currentUser || null,
announcements: announcements,
pagination: data.pagination || null
});
};
@@ -0,0 +1,434 @@
// Announcement route registration and status wiring.
module.exports = function registerAnnouncementRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const forwardAnnouncementRefresh = deps.forwardAnnouncementRefresh;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const requirePermission = deps.requirePermission;
const { buildPagination } = require('../../../lib/pagination');
const announcementData = require('#src/data/announcements');
const announcementIcons = require('#src/data/announcement-icons');
const LIST_PAGE_SIZE = 25;
const ANNOUNCEMENT_TYPES = [
{ value: 'lower-third', label: 'Lower third' },
{ value: 'fullscreen', label: 'Fullscreen' },
{ value: 'top-banner', label: 'Top banner' }
];
const ANNOUNCEMENT_COLORS = [
{ value: 'primary', label: 'Primary' },
{ value: 'secondary', label: 'Secondary' },
{ value: 'success', label: 'Success' },
{ value: 'info', label: 'Info' },
{ value: 'warning', label: 'Warning' },
{ value: 'danger', label: 'Danger' },
{ value: 'dark', label: 'Dark' },
{ value: 'light', label: 'Light' }
];
const ANNOUNCEMENT_ICONS = announcementIcons.ANNOUNCEMENT_ICON_OPTIONS || [];
function normalizeDurationUnit(value) {
return String(value || 'minutes').trim().toLowerCase() === 'seconds' ? 'seconds' : 'minutes';
}
function computeDurationSeconds(value, unit) {
const normalizedUnit = normalizeDurationUnit(unit);
const amount = Math.max(1, Math.floor(Number(value) || 0));
return normalizedUnit === 'seconds' ? amount : amount * 60;
}
function formatDurationLabel(seconds) {
const totalSeconds = Math.max(1, Math.round(Number(seconds) || 0));
if (totalSeconds % 60 === 0) {
const minutes = totalSeconds / 60;
return minutes === 1 ? '1 minute' : `${minutes} minutes`;
}
return totalSeconds === 1 ? '1 second' : `${totalSeconds} seconds`;
}
function isAnnouncementActive(announcement) {
if (!announcement || !announcement.expires_at) {
return true;
}
const expiresAt = new Date(announcement.expires_at).getTime();
if (Number.isNaN(expiresAt)) {
return true;
}
return expiresAt > Date.now();
}
function isAnnouncementDeletionBlocked(announcement) {
const screenIds = Array.isArray(announcement && announcement.screen_ids) ? announcement.screen_ids : [];
return isAnnouncementActive(announcement) || screenIds.length > 0;
}
async function fetchAllScreenSlugs() {
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
return (rows || [])
.map(function (row) {
return String(row && row.slug ? row.slug : '').trim();
})
.filter(Boolean);
}
async function fetchAnnouncementScreens() {
const [rows] = await pool.query('SELECT id, name, slug FROM d_screens ORDER BY name ASC, slug ASC');
return (rows || []).map(function (row) {
return {
id: Number(row && row.id) || null,
name: String(row && row.name ? row.name : '').trim(),
slug: String(row && row.slug ? row.slug : '').trim()
};
}).filter(function (screen) {
return Boolean(screen.id && screen.slug);
});
}
function parseSelectedScreenIds(value) {
const values = Array.isArray(value) ? value : value === undefined || value === null || value === '' ? [] : [value];
return values.map(function (screenId) {
return Number(screenId) || 0;
}).filter(function (screenId) {
return screenId > 0;
});
}
function buildAnnouncementScreenOptions(screens, selectedScreenIds) {
const normalizedSelectedIds = (Array.isArray(selectedScreenIds) ? selectedScreenIds : [])
.map(function (screenId) {
return Number(screenId) || 0;
})
.filter(function (screenId) {
return screenId > 0;
});
const selectedIds = new Set(normalizedSelectedIds);
return (screens || []).map(function (screen) {
return Object.assign({}, screen, {
isSelected: selectedIds.has(Number(screen && screen.id) || 0)
});
});
}
async function updateAnnouncementScreenTargets(announcementId, selectedScreenIds, actorId) {
const [screenRows] = await pool.query('SELECT id FROM d_screens ORDER BY id ASC');
const allScreenIds = (screenRows || []).map(function (row) {
return Number(row && row.id) || 0;
}).filter(function (screenId) {
return screenId > 0;
});
const selectedIds = parseSelectedScreenIds(selectedScreenIds).filter(function (screenId) {
return allScreenIds.indexOf(screenId) !== -1;
});
const [existingRows] = await pool.query('SELECT screen_id FROM d_announcement_screens WHERE announcement_id = ?', [announcementId]);
const existingIds = new Set((existingRows || []).map(function (row) {
return Number(row && row.screen_id) || 0;
}).filter(function (screenId) {
return screenId > 0;
}));
const selectedIdSet = new Set(selectedIds);
const removedIds = (existingRows || []).map(function (row) {
return Number(row && row.screen_id) || 0;
}).filter(function (screenId) {
return screenId > 0 && !selectedIdSet.has(screenId);
});
if (removedIds.length) {
await pool.query('DELETE FROM d_announcement_screens WHERE announcement_id = ? AND screen_id IN (?)', [announcementId, removedIds]);
}
if (selectedIds.length) {
await pool.query(
'INSERT INTO d_announcement_screens (announcement_id, screen_id, created_by, modified_by) VALUES ? ON DUPLICATE KEY UPDATE modified_at = CURRENT_TIMESTAMP, modified_by = VALUES(modified_by)',
[selectedIds.map(function (screenId) {
return [announcementId, screenId, actorId || null, actorId || null];
})]
);
}
}
async function refreshAnnouncementPlayers() {
if (typeof forwardAnnouncementRefresh !== 'function') {
return;
}
try {
const slugs = await fetchAllScreenSlugs();
if (!slugs.length) {
return;
}
await Promise.allSettled(slugs.map(function (slug) {
return forwardAnnouncementRefresh(slug);
}));
} catch (_error) {
// Keep announcement persistence independent from player refresh delivery.
}
}
async function deactivateConflictingAnnouncements(announcementId, screenIds, actorId) {
const normalizedScreenIds = (Array.isArray(screenIds) ? screenIds : [])
.map(function (screenId) {
return Number(screenId) || 0;
})
.filter(function (screenId) {
return screenId > 0;
});
if (!normalizedScreenIds.length) {
return;
}
const conflictingIds = new Set();
const [targetedRows] = await pool.query(
`SELECT DISTINCT a.id
FROM d_announcements a
JOIN d_announcement_screens restriction ON restriction.announcement_id = a.id
WHERE a.id <> ?
AND (a.expires_at IS NULL OR a.expires_at > CURRENT_TIMESTAMP)
AND restriction.screen_id IN (?)`,
[announcementId, normalizedScreenIds]
);
(targetedRows || []).forEach(function (row) {
const id = Number(row && row.id) || 0;
if (id > 0) {
conflictingIds.add(id);
}
});
if (!conflictingIds.size) {
return;
}
await pool.query(
'UPDATE d_announcements SET expires_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id IN (?)',
[actorId || null, Array.from(conflictingIds)]
);
}
function buildAnnouncementFormData(announcement) {
const normalized = announcement || {};
const durationSeconds = Number(normalized.duration_seconds || 0) || 0;
const durationValue = durationSeconds > 0 && durationSeconds % 60 === 0
? durationSeconds / 60
: durationSeconds;
const durationUnit = durationSeconds > 0 && durationSeconds % 60 === 0 ? 'minutes' : 'seconds';
const durationMode = String(normalized.durationMode || '').trim() || (durationSeconds > 0 ? 'duration' : 'until_disabled');
const isActive = isAnnouncementActive(normalized);
const canDelete = Boolean(normalized.id) && !isAnnouncementDeletionBlocked(normalized);
return Object.assign({}, normalized, {
durationMode: durationMode,
durationValue: durationSeconds > 0 ? durationValue : Number(normalized.durationValue || 5) || 5,
durationUnit: durationSeconds > 0 ? durationUnit : String(normalized.durationUnit || 'minutes'),
durationLabel: durationSeconds > 0 ? formatDurationLabel(durationSeconds) : 'Until disabled',
isActive: isActive,
actionLabel: isActive ? 'Stop' : 'Play',
actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
actionClassName: isActive ? 'btn-outline-warning' : 'btn-outline-success',
actionConfirmMessage: isActive
? 'Stop this announcement on the selected screens now?'
: 'Send this announcement to the selected screens now?',
actionPath: isActive
? `/announcements/${normalized.id}/stop`
: `/announcements/${normalized.id}/play`,
canDelete: canDelete,
deleteBlockedMessage: canDelete ? '' : 'This announcement is still in use by one or more screens.'
});
}
app.get('/announcements', requirePermission('announcements.read'), async function (req, res, next) {
try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req) || 'description';
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchAnnouncementsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderAnnouncementsPage({
announcements: data.announcements || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'announcements', 'Announcement pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) {
try {
const screens = await fetchAnnouncementScreens();
res.send(pages.renderAnnouncementFormPage(buildAnnouncementFormData({
message: '',
short_label: '',
announcement_type: 'lower-third',
color_key: 'primary',
icon_key: announcementIcons.DEFAULT_ANNOUNCEMENT_ICON,
durationMode: 'duration',
duration_seconds: null,
durationValue: 5,
durationUnit: 'minutes',
screen_ids: []
}), 'add', req.query.message ? String(req.query.message) : '', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
announcementScreens: buildAnnouncementScreenOptions(screens, [])
}));
} catch (error) {
next(error);
}
});
app.get('/announcements/:id/edit', requirePermission('announcements.update'), async function (req, res, next) {
try {
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
if (!announcement) {
return res.status(404).send('Announcement not found');
}
const screens = await fetchAnnouncementScreens();
res.send(pages.renderAnnouncementEditPage(buildAnnouncementFormData(announcement), null, req.query.message ? String(req.query.message) : '', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || [])
}));
} catch (error) {
next(error);
}
});
app.post('/announcements', requirePermission('announcements.create'), async function (req, res, next) {
try {
const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim();
const shortLabel = String(req.body.short_label || '').replace(/\r\n/g, ' ').trim();
const announcementType = common.normalizeAnnouncementType(req.body.announcement_type);
const colorKey = common.normalizeAnnouncementColor(req.body.color_key);
const iconKey = common.normalizeAnnouncementIcon(req.body.icon_key);
const durationMode = String(req.body.duration_mode || 'until_disabled').trim();
const durationSeconds = durationMode === 'duration'
? computeDurationSeconds(req.body.duration_value, req.body.duration_unit)
: null;
const selectedScreenIds = parseSelectedScreenIds(req.body.screen_ids);
if (!message) {
return res.status(400).send('Announcement text is required.');
}
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'INSERT INTO d_announcements (message, short_label, announcement_type, color_key, icon_key, duration_seconds, expires_at, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP, ?, ?)',
[message, shortLabel, announcementType, colorKey, iconKey, durationSeconds, actorId, actorId]
);
await updateAnnouncementScreenTargets(result.insertId, selectedScreenIds, actorId);
redirectAfterSave(req, res, '/announcements?edit=' + result.insertId, {
closeUrl: '/announcements',
newUrl: '/announcements/new',
message: 'Announcement created.'
});
} catch (error) {
next(error);
}
});
app.post('/announcements/:id', requirePermission('announcements.update'), async function (req, res, next) {
try {
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
if (!announcement) {
return res.status(404).send('Announcement not found');
}
const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim();
const shortLabel = String(req.body.short_label || '').replace(/\r\n/g, ' ').trim();
const announcementType = common.normalizeAnnouncementType(req.body.announcement_type);
const colorKey = common.normalizeAnnouncementColor(req.body.color_key);
const iconKey = common.normalizeAnnouncementIcon(req.body.icon_key);
const durationMode = String(req.body.duration_mode || 'until_disabled').trim();
const durationSeconds = durationMode === 'duration'
? computeDurationSeconds(req.body.duration_value, req.body.duration_unit)
: null;
const selectedScreenIds = parseSelectedScreenIds(req.body.screen_ids);
if (!message) {
return res.status(400).send('Announcement text is required.');
}
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE d_announcements SET message = ?, short_label = ?, announcement_type = ?, color_key = ?, icon_key = ?, duration_seconds = ?, modified_by = ? WHERE id = ?',
[message, shortLabel, announcementType, colorKey, iconKey, durationSeconds, actorId, announcement.id]
);
await updateAnnouncementScreenTargets(announcement.id, selectedScreenIds, actorId);
redirectAfterSave(req, res, '/announcements?edit=' + announcement.id, {
closeUrl: '/announcements',
newUrl: '/announcements/new',
message: 'Announcement updated.'
});
} catch (error) {
next(error);
}
});
app.post('/announcements/:id/play', requirePermission('announcements.update'), async function (req, res, next) {
try {
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
if (!announcement) {
return res.status(404).send('Announcement not found');
}
await deactivateConflictingAnnouncements(announcement.id, announcement.screen_ids || [], getAuditUserId(req));
const durationSeconds = announcement.duration_seconds ? Number(announcement.duration_seconds) : null;
const expiresAt = durationSeconds ? new Date(Date.now() + (durationSeconds * 1000)) : null;
await pool.query('UPDATE d_announcements SET expires_at = ?, modified_by = ? WHERE id = ?', [expiresAt, getAuditUserId(req), announcement.id]);
await refreshAnnouncementPlayers();
if (req.xhr || String(req.headers.accept || '').includes('application/json')) {
return res.json({ ok: true, message: 'Announcement sent to screens.', announcementId: announcement.id });
}
res.redirect('/announcements?edit=' + announcement.id + '&message=' + encodeURIComponent('Announcement sent to screens.'));
} catch (error) {
next(error);
}
});
app.post('/announcements/:id/stop', requirePermission('announcements.update'), async function (req, res, next) {
try {
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
if (!announcement) {
return res.status(404).send('Announcement not found');
}
await pool.query('UPDATE d_announcements SET expires_at = CURRENT_TIMESTAMP, modified_by = ? WHERE id = ?', [getAuditUserId(req), announcement.id]);
await refreshAnnouncementPlayers();
if (req.xhr || String(req.headers.accept || '').includes('application/json')) {
return res.json({ ok: true, message: 'Announcement stopped.', announcementId: announcement.id });
}
res.redirect('/announcements?edit=' + announcement.id + '&message=' + encodeURIComponent('Announcement stopped.'));
} catch (error) {
next(error);
}
});
app.post('/announcements/:id/delete', requirePermission('announcements.delete'), async function (req, res, next) {
try {
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
if (!announcement) {
return res.status(404).send('Announcement not found');
}
if (isAnnouncementDeletionBlocked(announcement)) {
return res.redirect('/announcements/' + announcement.id + '/edit?message=' + encodeURIComponent('This announcement is still in use by one or more screens.'));
}
await pool.query('DELETE FROM d_announcements WHERE id = ?', [announcement.id]);
await refreshAnnouncementPlayers();
res.redirect('/announcements?message=' + encodeURIComponent('Announcement deleted.'));
} catch (error) {
next(error);
}
});
};
+4 -1
View File
@@ -1,3 +1,5 @@
// Canvas size add page renderer and form defaults.
const { renderView } = require('../../../view');
function buildDefaultCanvasSize() {
@@ -16,6 +18,7 @@ module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, cu
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: canvasSize || buildDefaultCanvasSize()
canvasSize: canvasSize || buildDefaultCanvasSize(),
inUse: Boolean(canvasSize && canvasSize.inUse)
});
};
@@ -1,3 +1,5 @@
// Canvas size edit page renderer that reuses the add form.
const renderCanvasSizeFormPage = require('./add');
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
@@ -1,3 +1,5 @@
// Canvas size list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderCanvasSizesPage(data, message, currentUser) {
@@ -1,3 +1,5 @@
// Canvas size route registration and pagination wiring.
module.exports = function registerCanvasSizeRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
@@ -5,7 +7,7 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
+2
View File
@@ -1,3 +1,5 @@
// Connected clients list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderConnectedClientsPage(data, message, currentUser) {
+3 -1
View File
@@ -1,3 +1,5 @@
// Connected client route registration and dashboard wiring.
module.exports = function registerClientsRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -7,7 +9,7 @@ module.exports = function registerClientsRoutes(app, deps) {
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
function sortClients(clients, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
@@ -1,3 +1,5 @@
// Dashboard page renderer for the signage overview.
const { renderView } = require('../../../view');
module.exports = function renderDashboardPage(data, message, currentUser) {
@@ -1,3 +1,5 @@
// Dashboard route registration for the signage overview page.
module.exports = function registerDashboardRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
+2
View File
@@ -1,3 +1,5 @@
// Playlist add page renderer.
const { renderView } = require('../../../view');
module.exports = function renderPlaylistFormPage(message, currentUser) {
+4 -1
View File
@@ -1,3 +1,5 @@
// Playlist edit page renderer.
const { renderView } = require('../../../view');
function formatDateTime(value) {
@@ -208,6 +210,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
message: message,
currentUser: currentUser || null,
playlist: playlist,
inUse: Boolean(playlist.inUse),
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
playlistSlides: playlistSlides,
@@ -218,6 +221,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'js/vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
scripts: ['js/lib/modal.js?v=20260726.7', 'vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
});
};
+7 -1
View File
@@ -1,3 +1,5 @@
// Playlist list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderPlaylistsPage(data, message, currentUser) {
@@ -5,9 +7,13 @@ module.exports = function renderPlaylistsPage(data, message, currentUser) {
const slideCount = playlist.slide_count !== undefined
? Number(playlist.slide_count) || 0
: (data.playlistSlides || []).filter((item) => item.playlist_id === playlist.id).length;
const screenCount = playlist.screen_count !== undefined
? Number(playlist.screen_count) || 0
: 0;
return Object.assign({}, playlist, {
slideCount: slideCount,
slideCountIsOne: slideCount === 1
screenCount: screenCount,
screenCountIsOne: screenCount === 1
});
});
+6 -1
View File
@@ -1,11 +1,14 @@
// Playlist route registration and pagination wiring.
module.exports = function registerPlaylistsRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
@@ -26,6 +29,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
if (!playlist) {
return res.status(404).send('Playlist not found');
}
playlist.inUse = (await fetchScreensByPlaylistId(pool, playlist.id)).length > 0;
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
}
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
@@ -49,6 +53,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
playlist.inUse = (await fetchScreensByPlaylistId(pool, playlist.id)).length > 0;
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -1,3 +1,5 @@
// Shared playlist slide configuration rendering helpers.
const { renderFragment } = require('../../../view');
const DAY_OPTIONS = [
+2
View File
@@ -1,3 +1,5 @@
// Screen add page renderer.
const { renderView } = require('../../../view');
module.exports = function renderScreenFormPage(data, message, currentUser) {
+3
View File
@@ -1,3 +1,5 @@
// Screen edit page renderer.
const { renderView } = require('../../../view');
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
@@ -7,6 +9,7 @@ module.exports = function renderScreenEditPage(screen, data, message, currentUse
message: message,
currentUser: currentUser || null,
screen: screen,
inUse: Boolean(screen.inUse),
playlists: data.playlists || []
});
};
+2
View File
@@ -1,3 +1,5 @@
// Screen list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderScreensPage(data, message, currentUser) {
+28 -3
View File
@@ -1,12 +1,15 @@
// Screen route registration and dashboard wiring.
module.exports = function registerScreensRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
@@ -18,6 +21,7 @@ module.exports = function registerScreensRoutes(app, deps) {
function applyConnectionCounts(screens, dashboardScreens) {
const countsById = new Map();
const countsBySlug = new Map();
const dashboardScreenBySlug = new Map();
(dashboardScreens || []).forEach(function (screen) {
const count = Number(screen && screen.player_connection_count || 0);
@@ -29,18 +33,24 @@ module.exports = function registerScreensRoutes(app, deps) {
}
if (slug) {
countsBySlug.set(slug, count);
dashboardScreenBySlug.set(slug, screen);
}
if (id) {
dashboardScreenBySlug.set(id, screen);
}
});
return (screens || []).map(function (screen) {
const slug = String(screen && screen.slug || '').trim();
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
const dashboardScreen = dashboardScreenBySlug.get(slug) || dashboardScreenBySlug.get(id) || null;
const playerConnectionCount = countsBySlug.has(slug)
? countsBySlug.get(slug)
: countsById.get(id) || 0;
return Object.assign({}, screen, {
player_connection_count: playerConnectionCount
player_connection_count: playerConnectionCount,
player_url: dashboardScreen && dashboardScreen.player_url ? dashboardScreen.player_url : screen.player_url || null
});
});
}
@@ -52,6 +62,7 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
}
@@ -60,9 +71,18 @@ module.exports = function registerScreensRoutes(app, deps) {
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const dashboardState = await buildDashboardState(pool);
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderScreensPage({
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []).map(function (screen) {
const slug = String(screen && screen.slug || '').trim();
const registryUrl = slug && playerUrlsBySlug[slug] ? String(playerUrlsBySlug[slug]).trim() : '';
return Object.assign({}, screen, {
player_url: screen.player_url || registryUrl || null
});
}),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
@@ -76,6 +96,11 @@ module.exports = function registerScreensRoutes(app, deps) {
if (!screen) {
return res.status(404).send('Screen not found');
}
const playerUrlsBySlug = typeof common.fetchScreenPlayerUrls === 'function'
? await common.fetchScreenPlayerUrls(pool)
: {};
screen.player_url = screen.slug && playerUrlsBySlug[screen.slug] ? String(playerUrlsBySlug[screen.slug]).trim() : null;
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
+11 -1
View File
@@ -1,9 +1,14 @@
// Slide form page renderer for add and edit flows.
const { renderView } = require('../../../view');
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) {
const assetVersion = Date.now().toString(36);
const templateRegions = data.templateRegions || [];
const rssFeeds = data.rssFeeds || [];
const apiSources = data.apiSources || [];
const fontLibrary = data.fontLibrary || null;
const templates = (data.templates || []).map((template) => ({
...template,
canvas_size_width: template.canvas_size_width,
@@ -15,18 +20,23 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
active: 'slides',
message: message,
isEdit: mode === 'edit',
inUse: Boolean(slide && slide.inUse),
saveLabel: 'Save',
action: mode === 'edit' ? `/slides/${slide.id}` : '/slides',
slide: slide || { title: '', template_id: null, content: {} },
templates: templates,
stylesheets: ['js/vendor/ckeditor5/ckeditor5.css'],
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
slideEditorData: {
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
existingContent: slide && slide.content ? slide.content : {}
},
assetVersion: assetVersion,
slideEditorScripts: getRegionEditorScripts(assetVersion),
currentUser: currentUser || null
});
};
+2
View File
@@ -1,3 +1,5 @@
// Slide list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderSlidesPage(data, message, currentUser) {
+14 -1
View File
@@ -1,3 +1,7 @@
// Slide route registration and pagination wiring.
const { renderFragment } = require('../../../view');
module.exports = function registerSlidesRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
@@ -5,7 +9,7 @@ module.exports = function registerSlidesRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
@@ -22,5 +26,14 @@ module.exports = function registerSlidesRoutes(app, deps) {
next(error);
}
});
app.get('/slides/popup-preview', requirePermission('slides.read'), function (_req, res) {
res.send(renderFragment('slides/popup-preview', {
title: 'Slide preview',
framePopupCard: true,
hideFrameHeader: true,
frameBodyClass: 'slide-preview-popup-shell slide-preview-popup-body'
}));
});
};
+6 -1
View File
@@ -1,4 +1,7 @@
// Template add page renderer and form defaults.
const { renderView } = require('../../../view');
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
function buildDefaultTemplate() {
return {
@@ -43,6 +46,7 @@ function resolveTemplateCanvasSize(template, canvasSizes) {
module.exports = function renderTemplateFormPage(template, mode, message, canvasSizes, currentUser) {
const isEdit = mode === 'edit';
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
const assetVersion = Date.now().toString(36);
return renderView(isEdit ? 'templates/edit' : 'templates/add', {
title: isEdit ? 'Edit template' : 'Create Template',
@@ -50,7 +54,8 @@ module.exports = function renderTemplateFormPage(template, mode, message, canvas
message: message,
currentUser: currentUser || null,
template: current,
inUse: Boolean(current.inUse),
canvasSizes: canvasSizes || [],
scripts: ['js/lib/modal.js', 'js/templates/template-designer-utils.js', 'js/templates/template-designer.js']
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
});
};
+2
View File
@@ -1,3 +1,5 @@
// Template edit page renderer that reuses the add form.
const renderTemplateFormPage = require('./add');
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
+2
View File
@@ -1,3 +1,5 @@
// Template list page renderer.
const { renderView } = require('../../../view');
module.exports = function renderTemplatesPage(data, message, currentUser) {
+3 -1
View File
@@ -1,3 +1,5 @@
// Template route registration and pagination wiring.
module.exports = function registerTemplatesRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
@@ -5,7 +7,7 @@ module.exports = function registerTemplatesRoutes(app, deps) {
const { buildPagination } = require('../../../lib/pagination');
const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10;
const LIST_PAGE_SIZE = 25;
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {