Release v2.4.2

This commit is contained in:
2026-08-03 12:45:27 +01:00
parent 2b9cabdab2
commit e95928f31c
141 changed files with 7694 additions and 5078 deletions
+4 -37
View File
@@ -1,41 +1,8 @@
// Announcement add page renderer and form defaults.
// Announcement add page renderer.
const { renderView } = require('../../../view');
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
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
});
module.exports = function renderAnnouncementAddPage(announcement, message, currentUser, options) {
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Announcement edit page renderer that reuses the add form.
// Announcement edit page renderer.
const renderAnnouncementFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildAnnouncementFormViewModel } = require('./form-view-model');
module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) {
return renderAnnouncementFormPage(announcement, 'edit', message, currentUser, options);
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, true));
};
@@ -0,0 +1,54 @@
// Shared announcement form view-model builder.
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: []
};
}
function buildAnnouncementFormViewModel(announcement, message, currentUser, options, isEdit) {
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 : [];
const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {});
const canDelete = Boolean(currentUser && Array.isArray(currentUser.permissionKeys) && currentUser.permissionKeys.indexOf('announcements.delete') !== -1);
return {
title: isEdit ? 'Edit announcement' : 'Add announcement',
active: 'announcements',
message: message,
currentUser: currentUser || null,
announcement: viewAnnouncement,
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
showDeleteAction: Boolean(isEdit && canDelete),
deleteDisabled: !isEdit || !Boolean(viewAnnouncement && viewAnnouncement.canDelete),
formAction: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id : '/announcements',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/announcements"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/announcements/new"',
cancelUrl: '/announcements',
deleteUrl: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id + '/delete' : '',
actionFormId: 'announcement-action-form',
announcementTypes: announcementTypes,
announcementColors: announcementColors,
announcementIcons: announcementIcons,
announcementScreens: announcementScreens
};
}
module.exports = {
buildAnnouncementFormViewModel: buildAnnouncementFormViewModel
};
@@ -261,7 +261,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) {
try {
const screens = await fetchAnnouncementScreens();
res.send(pages.renderAnnouncementFormPage(buildAnnouncementFormData({
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData({
message: '',
short_label: '',
announcement_type: 'lower-third',
@@ -272,7 +272,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
durationValue: 5,
durationUnit: 'minutes',
screen_ids: []
}), 'add', req.query.message ? String(req.query.message) : '', req.currentUser, {
}), req.query.message ? String(req.query.message) : '', req.currentUser, {
announcementTypes: ANNOUNCEMENT_TYPES,
announcementColors: ANNOUNCEMENT_COLORS,
announcementIcons: ANNOUNCEMENT_ICONS,
+4 -20
View File
@@ -1,24 +1,8 @@
// Canvas size add page renderer and form defaults.
// Canvas size add page renderer.
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
function buildDefaultCanvasSize() {
return {
id: null,
name: '',
width: 1920,
height: 1080
};
}
module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, currentUser) {
const isEdit = mode === 'edit';
return renderView(isEdit ? 'canvas-sizes/edit' : 'canvas-sizes/add', {
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: canvasSize || buildDefaultCanvasSize(),
inUse: Boolean(canvasSize && canvasSize.inUse)
});
module.exports = function renderCanvasSizeAddPage(canvasSize, message, currentUser) {
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Canvas size edit page renderer that reuses the add form.
// Canvas size edit page renderer.
const renderCanvasSizeFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
return renderCanvasSizeFormPage(canvasSize, 'edit', message, currentUser);
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, true));
};
@@ -0,0 +1,34 @@
// Shared canvas size form view-model builder.
function buildDefaultCanvasSize() {
return {
id: null,
name: '',
width: 1920,
height: 1080
};
}
function buildCanvasSizeFormViewModel(canvasSize, message, currentUser, isEdit) {
const viewCanvasSize = Object.assign(buildDefaultCanvasSize(), canvasSize || {});
return {
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: viewCanvasSize,
inUse: Boolean(viewCanvasSize && viewCanvasSize.inUse),
isEdit: Boolean(isEdit),
formAction: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id : '/canvas-sizes',
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/canvas-sizes"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/canvas-sizes/new"',
cancelUrl: '/canvas-sizes',
deleteUrl: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id + '/delete' : '',
deleteConfirmMessage: 'Delete this canvas size?',
deleteDisabled: !isEdit || Boolean(viewCanvasSize && viewCanvasSize.inUse)
};
}
module.exports = {
buildCanvasSizeFormViewModel: buildCanvasSizeFormViewModel
};
+1
View File
@@ -9,6 +9,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser)
message: message,
currentUser: currentUser || null,
scripts: ['js/dashboard/dashboard-page.js'],
screens: data.screens || [],
clients: data.clients || [],
pagination: data.pagination || null
});
+1
View File
@@ -60,6 +60,7 @@ module.exports = function registerClientsRoutes(app, deps) {
const startIndex = (page - 1) * LIST_PAGE_SIZE;
const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
res.send(pages.renderConnectedClientsPage({
screens: data.screens || [],
clients: clients,
pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser));
+4 -12
View File
@@ -1,17 +1,9 @@
// Playlist add page renderer.
const { renderView } = require('../../../view');
const renderPlaylistEditorPage = require('./form-page');
module.exports = function renderPlaylistFormPage(message, currentUser) {
return renderView('playlists/add', {
title: 'Create playlist',
active: 'playlists',
playlist: {
fade_between_slides: false,
skip_unavailable_rtmp: false
},
message: message,
currentUser: currentUser || null
});
module.exports = async function renderPlaylistFormPage(message, currentUser, data, pool) {
const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] });
return renderPlaylistEditorPage(null, adminData, message, currentUser, { isEdit: false });
};
+2 -221
View File
@@ -1,226 +1,7 @@
// Playlist edit page renderer.
const { renderView } = require('../../../view');
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function formatDays(daysJson) {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let days = [];
if (Array.isArray(daysJson)) {
days = daysJson;
} else if (daysJson) {
try {
const parsed = JSON.parse(daysJson);
days = Array.isArray(parsed) ? parsed : [];
} catch (_error) {
days = [];
}
}
return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', ');
}
function normalizeScheduleDaysJson(daysJson) {
if (Array.isArray(daysJson)) {
return JSON.stringify(daysJson.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
if (!daysJson) {
return '[]';
}
try {
const parsed = JSON.parse(daysJson);
if (Array.isArray(parsed)) {
return JSON.stringify(parsed.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
} catch (_error) {
return JSON.stringify(String(daysJson).split(',').map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
}
return '[]';
}
function scheduleSummary(item) {
const mode = String(item.schedule_mode || 'always');
if (mode === 'dates') {
const start = formatDateTime(item.schedule_start_datetime);
const end = formatDateTime(item.schedule_end_datetime);
return start && end ? `Dates: ${start} to ${end}` : 'Dates: not set';
}
if (mode === 'times') {
const days = formatDays(item.schedule_days_json);
const start = formatTime(item.schedule_start_time);
const end = formatTime(item.schedule_end_time);
return days && start && end ? `Times: ${days} ${start}-${end}` : 'Times: not set';
}
return 'Always visible';
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const contentJson = slide.content_json;
if (!contentJson) {
return '';
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return '';
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : '';
} catch (_error) {
return '';
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
const renderPlaylistEditorPage = require('./form-page');
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
const playlistSlides = (data.playlistSlides || [])
.filter((item) => item.playlist_id === playlist.id)
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id - b.id;
})
.map((item, index, items) => Object.assign({}, item, {
displayOrder: index + 1,
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
showVideoDurationButton: hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
durationSeconds: getVideoDurationValue(item),
schedule_days_json: normalizeScheduleDaysJson(item.schedule_days_json),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
const playlistCanvasSignatures = Array.from(new Set(playlistSlides.map((item) => getCanvasSignature(item.canvas_width, item.canvas_height)).filter(Boolean)));
const playlistCanvasSignature = playlistCanvasSignatures.length === 1 ? playlistCanvasSignatures[0] : null;
const playlistCanvasMismatch = playlistCanvasSignatures.length > 1;
const selectableSlides = (data.slides || []).filter((slide) => {
if (playlistCanvasMismatch) {
return false;
}
if (!playlistCanvasSignature) {
return true;
}
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
return renderView('playlists/edit', {
title: 'Edit playlist',
active: 'playlists',
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,
slides: data.slides || [],
hasSlides: (data.slides || []).length > 0,
hasSelectableSlides: selectableSlides.length > 0,
hasAddableSlides: hasAddableSlides,
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
});
return renderPlaylistEditorPage(playlist, data || {}, message, currentUser, { isEdit: true });
};
@@ -0,0 +1,8 @@
// Shared playlist form page renderer.
const { renderView } = require('../../../view');
const { buildPlaylistFormViewModel } = require('./form-view-model');
module.exports = function renderPlaylistFormPage(playlist, data, message, currentUser, options) {
return renderView('playlists/form', buildPlaylistFormViewModel(playlist || null, data || {}, message, currentUser, options || {}));
};
@@ -0,0 +1,289 @@
// Shared playlist form view-model builder.
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function scheduleSummary(item) {
return formatScheduleRulesSummary(item && item.scheduleRules ? item.scheduleRules : []);
}
function formatScheduleRuleForView(rule, index) {
const input = rule && typeof rule === 'object' ? rule : {};
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
const selectedDays = new Set(normalizeDayValues(input.days || input.schedule_days_json));
const normalizedDays = Array.from(selectedDays);
return {
index: index + 1,
position: index,
startDateTimeValue: formatDateTime(input.start_datetime),
endDateTimeValue: formatDateTime(input.end_datetime),
startTimeValue: formatTime(input.start_time),
endTimeValue: formatTime(input.end_time),
daysCsv: normalizedDays.join(','),
ruleSummary: formatScheduleRuleSummary(input),
dayOptions: dayNames.map((label, value) => ({
value: value,
label: label,
checked: selectedDays.has(value),
inputId: 'schedule-rule-' + (index + 1) + '-day-' + value
}))
};
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function hasPlayableMediaRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
const regionType = String(region && region.type || '').trim().toLowerCase();
return region && typeof region === 'object' && (regionType === 'video' || regionType === 'rtmp');
}));
} catch (_error) {
return false;
}
}
function getDisableAudioValue(slide) {
if (!slide || slide.disable_audio === undefined || slide.disable_audio === null) {
return true;
}
return Boolean(slide.disable_audio);
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const contentJson = slide.content_json;
if (!contentJson) {
return '';
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return '';
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : '';
} catch (_error) {
return '';
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
function formatCanvasSizeLabel(size) {
const width = Number(size && size.width);
const height = Number(size && size.height);
const dimensions = Number.isFinite(width) && Number.isFinite(height) ? width + 'x' + height : '';
const name = String(size && size.name ? size.name : '').trim();
if (!dimensions) {
return name;
}
return name ? dimensions + ' - ' + name : dimensions;
}
function buildPlaylistFormViewModel(playlist, data, message, currentUser, options) {
const isEdit = Boolean(options && options.isEdit);
const viewPlaylist = Object.assign({
id: null,
name: '',
fade_between_slides: false,
skip_unavailable_rtmp: false
}, playlist || {});
const playlistSlides = (data.playlistSlides || [])
.filter((item) => Number(item.playlist_id) === Number(viewPlaylist.id))
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id - b.id;
})
.map((item, index, items) => Object.assign({}, item, {
row_key: String(item.row_key || item.id || ''),
displayOrder: index + 1,
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSizeId: Number(item.canvas_size_id) || null,
showVideoDurationButton: hasVideoRegion(item.content_json),
showMuteButton: hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
disableAudio: getDisableAudioValue(item),
durationSeconds: getVideoDurationValue(item),
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
const storedCanvasId = Number(viewPlaylist.canvas_id) || null;
const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0)));
const computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null;
const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId;
const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null;
const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, {
label: formatCanvasSizeLabel(size),
canvasId: Number(size.id) || null,
selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id))
}));
const playlistCanvasMismatch = Boolean(
(storedCanvasId && playlistSlides.some((item) => Number(item.canvasSizeId || item.canvas_size_id) > 0 && Number(item.canvasSizeId || item.canvas_size_id) !== storedCanvasId)) ||
(!storedCanvasId && playlistSlideCanvasIds.length > 1)
);
const selectableSlides = (data.slides || []).map((slide) => Object.assign({}, slide, {
canvasSizeId: Number(slide.canvas_size_id) || null,
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: hasVideoRegion(slide.content_json),
showMuteButton: hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const playlistSlideTemplate = {
id: '',
row_key: '',
slide_id: '',
title: '',
thumbnail_path: '',
canvas_width: '',
canvas_height: '',
canvasSizeId: '',
showVideoDurationButton: false,
showMuteButton: false,
videoSourcePath: '',
videoDurationSeconds: '',
useVideoDuration: false,
disableAudio: true,
durationSeconds: 10,
scheduleRules: [],
scheduleSummary: 'Always visible',
displayOrder: ''
};
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned && (!playlistCanvasId || slide.canvasSizeId === playlistCanvasId));
const isCreate = !isEdit;
return {
title: 'Playlist editor',
active: 'playlists',
isEdit: isEdit,
playlist: viewPlaylist,
formAction: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id : '/playlists',
formAttrs: 'data-async-save data-async-save-refresh-target="#playlist-items-body" data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new" data-async-save-new-redirect="response-url"',
cancelUrl: '/playlists',
deleteUrl: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id + '/delete' : '',
deleteDisabled: isCreate ? true : Boolean(viewPlaylist.inUse),
showSaveSecondaryActions: true,
playlistFadeBetweenSlides: Boolean(viewPlaylist.fade_between_slides),
playlistSkipUnavailableRtmp: Boolean(viewPlaylist.skip_unavailable_rtmp),
canvasSizes: canvasSizes,
playlistCanvasSizeId: selectedCanvasSize ? Number(selectedCanvasSize.id) : '',
playlistCanvasSizeLabel: selectedCanvasSize ? formatCanvasSizeLabel(selectedCanvasSize) : '',
playlistCanvasEditable: Boolean(!isEdit || !playlistCanvasId),
playlistScheduleConfigUrl: '/playlists/new/slides/0/config',
hasSlides: (data.slides || []).length > 0,
hasSelectableSlides: selectableSlides.length > 0,
hasAddableSlides: hasAddableSlides,
playlistSlides: playlistSlides,
playlistSlideTemplate: playlistSlideTemplate,
slides: data.slides || [],
selectableSlides: selectableSlides,
playlistCanvasId: playlistCanvasId,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260803.1'],
message: message,
currentUser: currentUser || null,
inUse: Boolean(viewPlaylist.inUse)
};
}
module.exports = {
buildPlaylistFormViewModel: buildPlaylistFormViewModel
};
+6 -1
View File
@@ -43,7 +43,12 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
});
app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) {
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
(async function () {
const data = await common.fetchAdminData(pool);
res.send(await pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser, data, pool));
})().catch(function (error) {
res.status(500).send(error.message || 'Unable to load page.');
});
});
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
@@ -1,6 +1,7 @@
// Shared playlist slide configuration rendering helpers.
const { renderFragment } = require('../../../view');
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
const DAY_OPTIONS = [
{ value: 0, label: 'Sun' },
@@ -41,17 +42,34 @@ function formatTimeInput(value) {
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
const slide = playlistSlide || {
id: 0,
schedule_mode: 'always',
schedule_days: [],
schedule_start_datetime: null,
schedule_end_datetime: null,
schedule_start_time: null,
schedule_end_time: null
scheduleRules: []
};
const scheduleMode = String(slide.schedule_mode || 'always');
const selectedDays = new Set(Array.isArray(slide.schedule_days) ? slide.schedule_days.map(Number) : []);
const startTimeValue = formatTimeInput(slide.schedule_start_time) || (scheduleMode === 'times' ? DEFAULT_START_TIME : '');
const endTimeValue = formatTimeInput(slide.schedule_end_time) || (scheduleMode === 'times' ? DEFAULT_END_TIME : '');
const scheduleRules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
const renderedRules = scheduleRules.map((rule, index) => {
const ruleNumber = index + 1;
const selectedDays = new Set(normalizeDayValues(rule.days || rule.schedule_days_json));
return {
id: 'schedule-rule-' + ruleNumber,
index: ruleNumber,
startDateTimeValue: formatDateTimeInput(rule.start_datetime),
endDateTimeValue: formatDateTimeInput(rule.end_datetime),
startTimeValue: formatTimeInput(rule.start_time),
endTimeValue: formatTimeInput(rule.end_time),
ruleSummary: formatScheduleRuleSummary(rule),
dayOptions: DAY_OPTIONS.map((day) => ({
...day,
checked: selectedDays.has(day.value),
inputId: 'schedule-rule-' + ruleNumber + '-day-' + day.value
}))
};
});
const templateDayOptions = DAY_OPTIONS.map((day) => ({
...day,
checked: false,
inputId: 'schedule-rule-template-day-' + day.value
}));
return renderFragment('playlists/slide-config', {
title: 'Slide schedule',
@@ -63,19 +81,10 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
currentUser: currentUser || null,
playlist: playlist,
playlistSlide: slide,
scheduleMode: scheduleMode,
isAlwaysMode: scheduleMode === 'always',
isDatesMode: scheduleMode === 'dates',
isTimesMode: scheduleMode === 'times',
rowKey: String(rowKey || ''),
startDateTimeValue: formatDateTimeInput(slide.schedule_start_datetime),
endDateTimeValue: formatDateTimeInput(slide.schedule_end_datetime),
startTimeValue: startTimeValue,
endTimeValue: endTimeValue,
dayOptions: DAY_OPTIONS.map((day) => ({
...day,
checked: selectedDays.has(day.value)
})),
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js']
scheduleRules: renderedRules,
templateDayOptions: templateDayOptions,
scheduleRulesSummary: formatScheduleRulesSummary(scheduleRules),
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js?v=20260802.8']
});
};
+2 -7
View File
@@ -1,14 +1,9 @@
// Screen add page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
module.exports = function renderScreenFormPage(data, message, currentUser) {
return renderView('screens/add', {
title: 'Add screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
playlists: data.playlists || []
});
return renderView('screens/form', buildScreenFormViewModel(null, data || {}, message, currentUser, false));
};
+2 -9
View File
@@ -1,16 +1,9 @@
// Screen edit page renderer.
const { renderView } = require('../../../view');
const { buildScreenFormViewModel } = require('./form-view-model');
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
return renderView('screens/edit', {
title: 'Edit screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
screen: screen,
inUse: Boolean(screen.inUse),
playlists: data.playlists || []
});
return renderView('screens/form', buildScreenFormViewModel(screen, data || {}, message, currentUser, true));
};
@@ -0,0 +1,32 @@
// Shared screen form view-model builder.
function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
const viewScreen = Object.assign({
name: '',
slug: '',
playlist_id: null
}, screen || {});
return {
title: isEdit ? 'Edit screen' : 'Add screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
playlists: (data && data.playlists) || [],
screen: viewScreen,
inUse: Boolean(viewScreen.inUse),
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
formAction: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id : '/screens',
formAttrs: Boolean(isEdit)
? 'data-async-save data-async-save-close-url="/screens" data-async-save-new-url="/screens/new"'
: 'data-async-save data-async-save-new-redirect="response-url" data-async-save-new-url="/screens/new"',
cancelUrl: '/screens',
deleteUrl: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id + '/delete' : ''
};
}
module.exports = {
buildScreenFormViewModel: buildScreenFormViewModel
};
+8
View File
@@ -0,0 +1,8 @@
// Slide add page renderer.
const { renderView } = require('../../../view');
const { buildSlideFormViewModel } = require('./form-view-model');
module.exports = function renderSlideAddPage(data, message, currentUser) {
return renderView('slides/form', buildSlideFormViewModel(data || {}, null, message, currentUser, false));
};
+8
View File
@@ -0,0 +1,8 @@
// Slide edit page renderer.
const { renderView } = require('../../../view');
const { buildSlideFormViewModel } = require('./form-view-model');
module.exports = function renderSlideEditPage(data, slide, message, currentUser) {
return renderView('slides/form', buildSlideFormViewModel(data || {}, slide || null, message, currentUser, true));
};
@@ -0,0 +1,63 @@
// Shared slide form view-model builder.
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
function buildDefaultSlide() {
return {
title: '',
template_id: null,
content: {}
};
}
function buildTemplates(templates, templateRegions) {
const regions = Array.isArray(templateRegions) ? templateRegions : [];
return (Array.isArray(templates) ? templates : []).map((template) => Object.assign({}, template, {
canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height,
regions: regions.filter((region) => Number(region.template_id) === Number(template.id))
}));
}
function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
const assetVersion = Date.now().toString(36);
const templateRegions = data && data.templateRegions ? data.templateRegions : [];
const rssFeeds = data && data.rssFeeds ? data.rssFeeds : [];
const apiSources = data && data.apiSources ? data.apiSources : [];
const timetableGroups = data && data.timetableGroups ? data.timetableGroups : [];
const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null;
const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions);
const viewSlide = slide || buildDefaultSlide();
return {
title: isEdit ? 'Edit slide' : 'Create slide',
active: 'slides',
message: message,
isEdit: Boolean(isEdit),
inUse: Boolean(viewSlide && viewSlide.inUse),
action: isEdit && viewSlide.id ? '/slides/' + viewSlide.id : '/slides',
formAttrs: isEdit
? 'data-async-save data-async-save-close-url="/slides" data-async-save-new-url="/slides/new"'
: 'data-async-save data-async-save-close-url="/slides" data-async-save-new-redirect="response-url" data-async-save-new-url="/slides/new"',
slide: viewSlide,
templates: templates,
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
slideEditorData: {
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
timetableGroups: timetableGroups,
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}
},
assetVersion: assetVersion,
slideEditorScripts: getRegionEditorScripts(assetVersion),
currentUser: currentUser || null
};
}
module.exports = {
buildSlideFormViewModel: buildSlideFormViewModel
};
-45
View File
@@ -1,45 +0,0 @@
// 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 scheduleGroups = data.scheduleGroups || [];
const fontLibrary = data.fontLibrary || null;
const templates = (data.templates || []).map((template) => ({
...template,
canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height,
regions: templateRegions.filter((region) => Number(region.template_id) === Number(template.id))
}));
return renderView('slides/form', {
title: mode === 'edit' ? 'Edit slide' : 'Create slide',
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: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
slideEditorData: {
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
scheduleGroups: scheduleGroups,
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
});
};
+4 -57
View File
@@ -1,61 +1,8 @@
// Template add page renderer and form defaults.
// Template add page renderer.
const { renderView } = require('../../../view');
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
const { buildTemplateFormViewModel } = require('./form-view-model');
function buildDefaultTemplate() {
return {
id: null,
name: '',
canvas_size_id: null,
canvas_size_width: 1920,
canvas_size_height: 1080,
background_color: '#111111',
background_image_path: '',
region_usage: [],
regions: []
};
}
function resolveTemplateCanvasSize(template, canvasSizes) {
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
if (matched) {
return Object.assign({}, template, {
canvas_size_id: matched.id,
canvas_size_label: matched.name,
canvas_size_width: matched.width,
canvas_size_height: matched.height,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
return Object.assign({}, template, {
canvas_size_id: '',
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
canvas_size_width: currentWidth,
canvas_size_height: currentHeight,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
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',
active: 'templates',
message: message,
currentUser: currentUser || null,
template: current,
inUse: Boolean(current.inUse),
canvasSizes: canvasSizes || [],
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])
});
module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser) {
return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false));
};
+4 -3
View File
@@ -1,7 +1,8 @@
// Template edit page renderer that reuses the add form.
// Template edit page renderer.
const renderTemplateFormPage = require('./add');
const { renderView } = require('../../../view');
const { buildTemplateFormViewModel } = require('./form-view-model');
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
return renderTemplateFormPage(template, 'edit', message, data && data.canvasSizes ? data.canvasSizes : [], currentUser);
return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true));
};
@@ -0,0 +1,70 @@
// Shared template form view-model builder.
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
function buildDefaultTemplate() {
return {
id: null,
name: '',
canvas_size_id: null,
canvas_size_width: 1920,
canvas_size_height: 1080,
background_color: '#111111',
background_image_path: '',
region_usage: [],
regions: []
};
}
function resolveTemplateCanvasSize(template, canvasSizes) {
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
if (matched) {
return Object.assign({}, template, {
canvas_size_id: matched.id,
canvas_size_label: matched.name,
canvas_size_width: matched.width,
canvas_size_height: matched.height,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
return Object.assign({}, template, {
canvas_size_id: '',
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
canvas_size_width: currentWidth,
canvas_size_height: currentHeight,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit) {
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
const assetVersion = Date.now().toString(36);
return {
title: isEdit ? 'Edit template' : 'Create Template',
active: 'templates',
message: message,
currentUser: currentUser || null,
template: current,
inUse: Boolean(current.inUse),
isEdit: Boolean(isEdit),
showSaveSecondaryActions: Boolean(isEdit),
deleteDisabled: !isEdit || Boolean(current.inUse),
formAction: isEdit && current.id ? '/templates/' + current.id : '/templates',
formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save' + (isEdit ? ' data-async-save-close-url="/templates"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"',
cancelUrl: '/templates',
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
canvasSizes: canvasSizes || [],
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])
};
}
module.exports = {
buildTemplateFormViewModel: buildTemplateFormViewModel
};