Implement schedule WYSIWYG and UTC dates

This commit is contained in:
2026-08-02 14:04:41 +01:00
parent a9d1d45d78
commit 2b9cabdab2
31 changed files with 1585 additions and 52 deletions
+6
View File
@@ -59,6 +59,12 @@ module.exports = {
getSortQuery: listQuery.getSortQuery,
getSortDirectionQuery: listQuery.getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById,
normalizeDisplayMode: data.normalizeDisplayMode,
fetchSchedulesData: data.fetchSchedulesData,
fetchScheduleGroupsPage: data.fetchScheduleGroupsPage,
fetchScheduleGroupById: data.fetchScheduleGroupById,
fetchScheduleEntriesByGroupId: data.fetchScheduleEntriesByGroupId,
buildScheduleGroupPayload: data.buildScheduleGroupPayload,
fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage,
fetchApiSourceById: data.fetchApiSourceById,
+7
View File
@@ -4,6 +4,7 @@ const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage,
const { ANNOUNCEMENT_TYPES, ANNOUNCEMENT_COLORS, ANNOUNCEMENT_ICONS, DEFAULT_ANNOUNCEMENT_ICON, normalizeAnnouncementType, normalizeAnnouncementColor, normalizeAnnouncementIcon, fetchAnnouncementsPage, fetchAnnouncementById, fetchActiveAnnouncement, buildAnnouncementPayload } = require('./announcements');
const { ANNOUNCEMENT_ICON_OPTIONS, ANNOUNCEMENT_ICON_LABELS } = require('./announcement-icons');
const { fetchPlaylistById } = require('./playlists');
const { normalizeDisplayMode, fetchSchedulesData, fetchScheduleGroupsPage, fetchScheduleGroupById, fetchScheduleEntriesByGroupId, buildScheduleGroupPayload } = require('./schedules');
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
@@ -36,6 +37,12 @@ module.exports = {
fetchActiveAnnouncement,
buildAnnouncementPayload,
fetchPlaylistById,
normalizeDisplayMode,
fetchSchedulesData,
fetchScheduleGroupsPage,
fetchScheduleGroupById,
fetchScheduleEntriesByGroupId,
buildScheduleGroupPayload,
fetchApiSourcesData,
fetchApiSourcesPage,
fetchApiSourceById,
+120
View File
@@ -0,0 +1,120 @@
// Schedule group and entry data access helpers.
const { fetchPagedRows } = require('./utils');
function normalizeDisplayMode(value) {
const mode = String(value || 'upcoming').trim().toLowerCase();
if (mode === 'current' || mode === 'both') {
return mode;
}
return 'upcoming';
}
async function fetchSchedulesData(pool) {
const [scheduleGroups] = await pool.query(`
SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_schedule_groups g
ORDER BY g.modified_at DESC, g.id DESC
`);
const [scheduleEntries] = await pool.query(`
SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
ORDER BY schedule_group_id ASC, start_datetime ASC, id ASC
`);
const entriesByGroupId = new Map();
scheduleEntries.forEach(function (entry) {
const groupId = Number(entry.schedule_group_id);
if (!entriesByGroupId.has(groupId)) {
entriesByGroupId.set(groupId, []);
}
entriesByGroupId.get(groupId).push(entry);
});
const groups = scheduleGroups.map(function (group) {
return Object.assign({}, group, {
entries: entriesByGroupId.get(Number(group.id)) || []
});
});
return {
scheduleGroups: groups,
scheduleEntries: scheduleEntries
};
}
async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
selectSql: `SELECT g.id, g.name, g.short_description, g.created_at, g.modified_at, g.created_by, g.modified_by,
(SELECT COUNT(*) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id) AS entry_count,
(SELECT MIN(e.start_datetime) FROM i_schedule_entries e WHERE e.schedule_group_id = g.id AND e.start_datetime IS NOT NULL) AS next_start_datetime
FROM i_schedule_groups g
ORDER BY g.modified_at DESC, g.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM i_schedule_groups',
searchColumns: ['g.name', 'g.short_description'],
searchTerm: searchTerm,
sortColumns: {
name: 'g.name',
description: 'g.short_description',
entries: 'entry_count',
next_start: 'next_start_datetime',
created: 'g.created_at',
modified: 'g.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page,
pageSize: pageSize
});
return Object.assign({ scheduleGroups: paged.rows }, paged);
}
async function fetchScheduleGroupById(pool, id) {
const [rows] = await pool.query(
'SELECT id, name, short_description, created_at, modified_at, created_by, modified_by FROM i_schedule_groups WHERE id = ?',
[id]
);
return rows[0] || null;
}
async function fetchScheduleEntriesByGroupId(pool, scheduleGroupId) {
const [rows] = await pool.query(
`SELECT id, schedule_group_id, title, short_description, start_datetime, end_datetime, created_at, modified_at, created_by, modified_by
FROM i_schedule_entries
WHERE schedule_group_id = ?
ORDER BY start_datetime ASC, id ASC`,
[scheduleGroupId]
);
return rows;
}
function buildScheduleGroupPayload(req, existingScheduleGroup) {
const fallback = existingScheduleGroup || {};
const name = String(req.body.name || fallback.name || '').trim();
const shortDescription = String(req.body.short_description || req.body.shortDescription || fallback.short_description || '').trim();
if (!name) {
const error = new Error('Schedule group name is required.');
error.statusCode = 400;
throw error;
}
return {
name: name,
shortDescription: shortDescription
};
}
module.exports = {
normalizeDisplayMode,
fetchSchedulesData,
fetchScheduleGroupsPage,
fetchScheduleGroupById,
fetchScheduleEntriesByGroupId,
buildScheduleGroupPayload
};
+1
View File
@@ -7,6 +7,7 @@ function createPool() {
user: process.env.DB_USER || 'signage_user',
password: process.env.DB_PASSWORD || 'signage_password',
database: process.env.DB_NAME || 'signage',
timezone: 'Z',
waitForConnections: true,
connectionLimit: 10,
namedPlaceholders: true
+29
View File
@@ -206,6 +206,35 @@ async function ensureSchema(pool, options) {
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_schedule_groups (
id INT AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(255) NOT NULL,
short_description VARCHAR(255) NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS i_schedule_entries (
id INT AUTO_INCREMENT PRIMARY KEY,
schedule_group_id INT NOT NULL,
title VARCHAR(255) NOT NULL,
short_description VARCHAR(255) NULL,
start_datetime DATETIME NOT NULL,
end_datetime DATETIME NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by INT NULL,
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
modified_by INT NULL,
CONSTRAINT fk_schedule_entries_group FOREIGN KEY (schedule_group_id) REFERENCES i_schedule_groups(id) ON DELETE CASCADE,
INDEX idx_schedule_entries_group_start (schedule_group_id, start_datetime)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
`);
await pool.query(`
CREATE TABLE IF NOT EXISTS d_onboarding_devices (
device_id VARCHAR(128) PRIMARY KEY,
+14 -4
View File
@@ -60,7 +60,7 @@ function createPlayerPlaylistService(options) {
try {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [] };
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], scheduleGroups: [] };
}
const screen = screenRows[0];
@@ -69,6 +69,9 @@ function createPlayerPlaylistService(options) {
screen: screen,
playlist: null,
slides: [],
rssFeeds: [],
apiSources: [],
scheduleGroups: [],
revision: getPlaylistRevision(screen, null, [], [], [], [], [])
};
await writeSnapshot(slug, payloadWithoutPlaylist);
@@ -184,8 +187,14 @@ function createPlayerPlaylistService(options) {
});
}
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, revision: revision };
let scheduleGroups = [];
if (typeof common.fetchSchedulesData === 'function') {
const scheduleData = await common.fetchSchedulesData(pool);
scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : [];
}
const revision = getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups);
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleGroups, revision: revision };
await writeSnapshot(slug, payload);
return payload;
} catch (error) {
@@ -202,7 +211,7 @@ function createPlayerPlaylistService(options) {
hash.update('\0');
}
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources) {
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups) {
const hash = crypto.createHash('sha1');
updatePlaylistRevisionHash(hash, screen && screen.id);
@@ -259,6 +268,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
updatePlaylistRevisionHash(hash, JSON.stringify(scheduleGroups || []));
return hash.digest('hex');
}
+17 -23
View File
@@ -463,29 +463,6 @@ body.screen-blackout #app {
font-size: 0.9rem;
}
.template-region table,
.template-region th,
.template-region td {
border: 1px solid rgba(255, 255, 255, 0.35);
border-collapse: collapse;
}
.template-region table {
width: 100%;
border-spacing: 0;
}
.template-region th,
.template-region td {
padding: 0.35em 0.5em;
text-align: left;
vertical-align: top;
}
.template-region th {
font-weight: 700;
}
.webpage-preloads {
position: fixed;
width: 1px;
@@ -503,3 +480,20 @@ body.screen-blackout #app {
border: 0;
display: block;
}
.template-region table {
width: 100%;
border-collapse: collapse;
border-spacing: 0;
}
.template-region th,
.template-region td {
padding: 0.35em 0.5em;
text-align: left;
vertical-align: top;
}
.template-region th {
font-weight: 700;
}
+20 -1
View File
@@ -1,6 +1,6 @@
// Service worker cache strategy for player pages, assets, media, and playlists.
const CACHE_VERSION = 'v36';
const CACHE_VERSION = 'v38';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
@@ -16,6 +16,20 @@ function normalizeRequest(request) {
});
}
function shouldBypassCache(request) {
if (!request) {
return false;
}
if (request.cache === 'reload' || request.cache === 'no-store') {
return true;
}
const cacheControl = String(request.headers.get('cache-control') || '').toLowerCase();
const pragma = String(request.headers.get('pragma') || '').toLowerCase();
return cacheControl.includes('no-cache') || cacheControl.includes('max-age=0') || pragma.includes('no-cache');
}
async function cacheResponse(cacheName, request, response, cacheKeyRequest) {
if (!response || !response.ok) {
return;
@@ -126,6 +140,11 @@ self.addEventListener('fetch', function (event) {
return;
}
if (shouldBypassCache(request)) {
event.respondWith(networkOnly(request));
return;
}
if (url.pathname.startsWith('/assets/')) {
event.respondWith(cacheFirst(request, ASSET_CACHE));
return;
+246
View File
@@ -0,0 +1,246 @@
// Schedule region rendering for live playback.
var registry = window.pulsePlayerRegionTypes;
function escapeHtml(value) {
return String(value === undefined || value === null ? '' : value)
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function sanitizeRichTextAttributes(tagName, attrText) {
var allowedAttributes = {
a: ['href', 'title', 'target', 'rel', 'class', 'style'],
blockquote: ['class', 'style'],
col: ['class', 'style', 'span', 'width'],
colgroup: ['class', 'style', 'span'],
div: ['class', 'style'],
figure: ['class', 'style'],
figcaption: ['class', 'style'],
h1: ['class', 'style'],
h2: ['class', 'style'],
h3: ['class', 'style'],
h4: ['class', 'style'],
h5: ['class', 'style'],
h6: ['class', 'style'],
li: ['class', 'style'],
ol: ['class', 'style', 'start'],
p: ['class', 'style'],
pre: ['class', 'style'],
span: ['class', 'style'],
table: ['class', 'style'],
tbody: ['class', 'style'],
td: ['class', 'style', 'colspan', 'rowspan'],
th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
thead: ['class', 'style'],
tr: ['class', 'style'],
ul: ['class', 'style']
};
var allowed = allowedAttributes[tagName] || [];
if (!allowed.length) {
return '';
}
var attrs = [];
String(attrText || '').replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
var lowerKey = String(key || '').toLowerCase();
if (allowed.indexOf(lowerKey) === -1) {
return '';
}
var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
return '';
}
if (lowerKey === 'target') {
var targetValue = String(value || '').trim();
if (targetValue === '_blank') {
attrs.push(' target="_blank"');
if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
attrs.push(' rel="noreferrer noopener"');
}
return '';
}
}
attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
return '';
});
return attrs.join('');
}
function sanitizeRichText(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, function (tag) {
var match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)([\s\S]*?)(\/?)>$/i);
if (!match) {
return '';
}
var closing = Boolean(match[1]);
var name = String(match[2] || '').toLowerCase();
var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'col', 'colgroup', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
if (allowed.indexOf(name) === -1) {
return '';
}
if (closing) {
return '</' + name + '>';
}
return '<' + name + sanitizeRichTextAttributes(name, String(match[3] || '')) + '>';
});
}
function substituteScheduleVariables(html, entry) {
var source = String(html || '');
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
if (!entry || typeof entry !== 'object') {
return '';
}
if (!window.placeholderUtils || typeof window.placeholderUtils.resolvePlaceholderExpression !== 'function' || typeof window.placeholderUtils.formatPlaceholderValue !== 'function') {
return '';
}
return escapeHtml(window.placeholderUtils.formatPlaceholderValue(window.placeholderUtils.resolvePlaceholderExpression(entry, expression)));
});
}
function renderTemplate(template, context) {
var source = String(template || '');
if (!source) {
return '';
}
return substituteScheduleVariables(source, context);
}
function getScheduleGroups() {
return Array.isArray(initialData && initialData.scheduleGroups) ? initialData.scheduleGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
function getEntries(groupId, groups) {
var group = getGroupById(groupId, groups);
return group && Array.isArray(group.entries) ? group.entries : [];
}
function toDate(value) {
if (!value) {
return null;
}
var date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isUpcoming(entry, now) {
var start = toDate(entry && entry.start_datetime);
return Boolean(start && now < start);
}
function isLive(entry, now) {
var start = toDate(entry && entry.start_datetime);
var end = toDate(entry && entry.end_datetime);
return Boolean(start && end && now >= start && now < end);
}
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
var now = new Date();
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
var leftStart = toDate(left && left.start_datetime);
var rightStart = toDate(right && right.start_datetime);
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
});
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
entries = entries.filter(function (entry) {
if (mode === 'current') {
return isLive(entry, now);
}
if (mode === 'both') {
return isUpcoming(entry, now) || isLive(entry, now);
}
return isUpcoming(entry, now);
});
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
function formatDateTime(value) {
var date = toDate(value);
if (!date) {
return '';
}
try {
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: '2-digit',
hour: 'numeric',
minute: '2-digit'
}).format(date);
} catch (_error) {
return date.toLocaleString();
}
}
function getDefaultStyle() {
return {
font_family: 'Arial',
font_size: 28,
font_color: '#000000'
};
}
function getTextStyle(region, regionContent) {
var current = regionContent && typeof regionContent === 'object' ? regionContent : {};
var defaultStyle = getDefaultStyle();
return {
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
};
}
function renderRegion(region, regionContent) {
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var style = getTextStyle(region, regionContent || {});
var groups = getScheduleGroups();
var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
if (!entries.length) {
entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5)));
}
if (!value) {
return '<div class="template-region schedule" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div></div>';
}
return '<div class="template-region schedule" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="schedule-region-entry" data-schedule-entry-index="' + index + '">' + sanitizeRichText(substituteScheduleVariables(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
entries: entries,
index: index + 1
}))) + '</div>';
}).join('') + '</div></div>';
}
registry.register('schedule', {
renderRegion: renderRegion
});
+1 -1
View File
@@ -27,7 +27,7 @@ function getPlayerServiceWorkerRegistrationScript() {
'<script>',
' if ("serviceWorker" in navigator) {',
' window.addEventListener("load", function () {',
' navigator.serviceWorker.register("/sw.js").catch(function () {',
' navigator.serviceWorker.register("/sw.js?v=38").catch(function () {',
' return null;',
' });',
' });',
+5
View File
@@ -259,6 +259,11 @@ function registerPlayerRoutes(app, options) {
});
}
if (typeof common.fetchSchedulesData === 'function') {
const scheduleData = await common.fetchSchedulesData(pool);
data.scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : [];
}
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
} catch (error) {
+1
View File
@@ -15,6 +15,7 @@ function buildThumbnailPreviewData(slide) {
slides: [slide],
rssFeeds: [],
apiSources: [],
scheduleGroups: [],
revision: String(slide.modified_at || slide.id || Date.now())
};
}
+12
View File
@@ -105,6 +105,18 @@ const PERMISSION_SECTIONS = [
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
]
},
{
key: 'schedules',
order: 77,
name: 'Schedules',
sectionName: 'Data Sources',
actions: [
{ key: 'read', name: 'Read', description: 'View configured schedule groups.' },
{ key: 'create', name: 'Create', description: 'Create new schedule groups.' },
{ key: 'update', name: 'Update', description: 'Update schedule groups and entries.' },
{ key: 'delete', name: 'Delete', description: 'Delete schedule groups.' }
]
},
{
key: 'api-sources',
order: 76,
+2
View File
@@ -21,6 +21,8 @@ module.exports = {
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
renderApiSourceEditPage: require(routePath('data-sources', 'api-sources', 'edit')),
renderScheduleGroupsPage: require(routePath('data-sources', 'schedules', 'list')),
renderScheduleGroupFormPage: require(routePath('data-sources', 'schedules', 'form')),
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
+9
View File
@@ -725,6 +725,10 @@ td[data-label="Slides"] {
position: relative;
}
.slide-editor-sidebar {
align-self: start;
}
.slide-form-stack {
display: grid;
gap: 1rem;
@@ -1734,6 +1738,11 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
position: static;
}
.slide-editor-sidebar {
position: static;
top: auto;
}
.slide-editor-shell {
grid-template-columns: minmax(0, 1fr);
}
@@ -0,0 +1,35 @@
// Minimal row editor for schedule groups.
(function () {
var form = document.getElementById('schedule-group-form');
var body = document.querySelector('[data-schedule-entries-body]');
var addButton = document.querySelector('[data-add-schedule-entry]');
var template = document.getElementById('schedule-entry-row-template');
if (!form || !body || !addButton || !template) {
return;
}
function bindRemove(row) {
var button = row.querySelector('[data-remove-schedule-entry]');
if (!button) {
return;
}
button.addEventListener('click', function () {
row.remove();
});
}
function addRow() {
var fragment = template.content.cloneNode(true);
var row = fragment.querySelector('[data-schedule-entry-row]');
if (!row) {
return;
}
bindRemove(row);
body.appendChild(fragment);
}
body.querySelectorAll('[data-schedule-entry-row]').forEach(bindRemove);
addButton.addEventListener('click', addRow);
}());
+338
View File
@@ -0,0 +1,338 @@
// Schedule region helpers for editor previews and defaults.
(function () {
var registry = window.pulseRegionTypes;
var utils = window.pulseRegionUtils || {};
var placeholderChips = window.placeholderChips || {};
var placeholderUtils = window.placeholderUtils || {};
var DEFAULT_STYLE = {
font_family: 'Arial',
font_size: 28,
font_color: '#000000'
};
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function sanitizeRichText(html) {
return utils.sanitizeRichText ? utils.sanitizeRichText(html) : escapeHtml(html);
}
function getDefaultStyle() {
return {
font_family: DEFAULT_STYLE.font_family,
font_size: DEFAULT_STYLE.font_size,
font_color: DEFAULT_STYLE.font_color
};
}
function getScheduleGroups() {
return Array.isArray(window.initialData && window.initialData.scheduleGroups) ? window.initialData.scheduleGroups : [];
}
function getGroupById(groupId, groups) {
var normalizedId = Number(groupId || 0);
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
return Number(group.id) === normalizedId;
}) || null;
}
function getEntries(groupId, groups) {
var group = getGroupById(groupId, groups);
return group && Array.isArray(group.entries) ? group.entries : [];
}
function toDate(value) {
if (!value) {
return null;
}
var date = value instanceof Date ? value : new Date(value);
return Number.isNaN(date.getTime()) ? null : date;
}
function isUpcoming(entry, now) {
var start = toDate(entry && entry.start_datetime);
return Boolean(start && now < start);
}
function isLive(entry, now) {
var start = toDate(entry && entry.start_datetime);
var end = toDate(entry && entry.end_datetime);
return Boolean(start && end && now >= start && now < end);
}
function getVisibleEntries(groupId, displayMode, maxItems, groups) {
var now = new Date();
var entries = getEntries(groupId, groups).slice().sort(function (left, right) {
var leftStart = toDate(left && left.start_datetime);
var rightStart = toDate(right && right.start_datetime);
return (leftStart ? leftStart.getTime() : 0) - (rightStart ? rightStart.getTime() : 0) || Number(left.id || 0) - Number(right.id || 0);
});
var mode = String(displayMode || 'upcoming').trim().toLowerCase();
entries = entries.filter(function (entry) {
if (mode === 'current') {
return isLive(entry, now);
}
if (mode === 'both') {
return isUpcoming(entry, now) || isLive(entry, now);
}
return isUpcoming(entry, now);
});
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
}
function renderTemplate(template, context) {
var source = String(template || '');
if (!source) {
return '';
}
if (placeholderUtils && typeof placeholderUtils.resolvePlaceholderExpression === 'function' && typeof placeholderUtils.formatPlaceholderValue === 'function') {
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
return escapeHtml(placeholderUtils.formatPlaceholderValue(placeholderUtils.resolvePlaceholderExpression(context, expression)));
});
}
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
var value = context;
String(expression || '').trim().split('.').forEach(function (segment) {
if (value === undefined || value === null) {
value = '';
return;
}
value = value[segment];
});
return escapeHtml(value);
});
}
function getCurrentConfig(region, existingContent) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
value: String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : ''),
max_items: Math.max(1, Number(current.max_items || 5)),
font_family: current.font_family || region.font_family || getDefaultStyle().font_family,
font_size: current.font_size || region.font_size || getDefaultStyle().font_size,
font_color: current.font_color || region.font_color || getDefaultStyle().font_color
};
}
function getSchedulePlaceholderTokens() {
return ['title', 'short_description', 'start', 'end'];
}
function renderSchedulePlaceholderChips(group, entries) {
var tokens = getSchedulePlaceholderTokens(group, entries);
if (placeholderChips && typeof placeholderChips.renderChips === 'function') {
return placeholderChips.renderChips(tokens);
}
return tokens.map(function (token) {
return '<span class="chip">{{' + escapeHtml(token) + '}}</span>';
}).join('');
}
function getCurrentSelection(regionId, card, existingContent, scheduleGroups) {
var current = existingContent[regionId] || {};
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + regionId + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + regionId + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + regionId + '"]') : null;
var groupId = scheduleGroupInput ? scheduleGroupInput.value : (current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id));
var displayMode = displayModeInput ? displayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var maxItems = Math.max(1, Number(maxItemsInput ? maxItemsInput.value : current.max_items || 5));
var group = getGroupById(groupId, scheduleGroups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, scheduleGroups);
if (!entries.length) {
entries = getEntries(groupId, scheduleGroups).slice(0, maxItems);
}
return {
group: group,
entries: entries,
displayMode: displayMode,
maxItems: maxItems
};
}
function renderEditorCard(context) {
var region = context.region;
var current = context.current || {};
var scheduleGroups = Array.isArray(context.scheduleGroups) ? context.scheduleGroups : [];
var currentValue = String(current.value !== undefined ? current.value : current.text !== undefined ? current.text : '');
var currentGroupId = current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id);
var currentDisplayMode = String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
var currentMaxItems = Math.max(1, Number(current.max_items || 5));
var currentGroup = getGroupById(currentGroupId, scheduleGroups);
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, scheduleGroups);
if (!currentEntries.length) {
currentEntries = getEntries(currentGroupId, scheduleGroups).slice(0, currentMaxItems);
}
var groupOptions = scheduleGroups.map(function (group) {
var selected = Number(group.id) === Number(currentGroupId) ? ' selected' : '';
return '<option value="' + escapeHtml(group.id) + '"' + selected + '>' + escapeHtml(group.name || ('Group ' + group.id)) + '</option>';
}).join('');
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" style="position:relative;overflow:visible !important;">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<div class="template-field-actions"><span class="chip">Schedule</span></div>' +
'</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="editor-holder" data-region-id="' + region.id + '">' +
'<textarea class="editor-source" rows="10">' + escapeHtml(currentValue) + '</textarea>' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(currentValue) + '" />' +
'</div>' +
'<div class="row g-3">' +
'<div class="col-12 col-lg-6">' +
'<label class="form-label" for="region_schedule_group_id_' + region.id + '">Schedule group</label>' +
'<select id="region_schedule_group_id_' + region.id + '" name="region_schedule_group_id_' + region.id + '" class="form-select">' +
'<option value="">Select a group</option>' +
groupOptions +
'</select>' +
'</div>' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_display_mode_' + region.id + '">Display mode</label>' +
'<select id="region_schedule_display_mode_' + region.id + '" name="region_schedule_display_mode_' + region.id + '" class="form-select">' +
'<option value="upcoming"' + (currentDisplayMode === 'upcoming' ? ' selected' : '') + '>Upcoming</option>' +
'<option value="current"' + (currentDisplayMode === 'current' ? ' selected' : '') + '>Current</option>' +
'<option value="both"' + (currentDisplayMode === 'both' ? ' selected' : '') + '>Both</option>' +
'</select>' +
'</div>' +
'</div>' +
'<div class="row g-3 align-items-end">' +
'<div class="col-6 col-lg-3">' +
'<label class="form-label" for="region_schedule_max_items_' + region.id + '">Max items</label>' +
'<input id="region_schedule_max_items_' + region.id + '" type="number" min="1" step="1" name="region_schedule_max_items_' + region.id + '" class="form-control" value="' + escapeHtml(currentMaxItems || 5) + '" />' +
'</div>' +
'<div class="col-12 col-lg-9 text-body-secondary small">Use the selected group and display mode to choose which schedule entry fields are available.</div>' +
'</div>' +
'<div class="api-region-placeholder-section schedule-placeholder-section" data-schedule-placeholder-chips>' +
'<div class="api-region-placeholder-title">Available placeholders</div>' +
'<div class="d-flex flex-wrap gap-2">' + renderSchedulePlaceholderChips(currentGroup, currentEntries) + '</div>' +
'<div class="text-body-secondary small mt-2">Format start and end values with expressions like <code>{{start.format("MMM D, YYYY h:mm A")}}</code> and <code>{{end.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
'</div>' +
'</div>' +
'</div>';
}
function getTextStyle(region, current) {
var defaultStyle = getDefaultStyle();
return {
font_family: String(current.font_family || region.font_family || defaultStyle.font_family || 'Arial').trim() || 'Arial',
font_size: Math.max(8, Number(current.font_size || region.font_size || defaultStyle.font_size || 28)),
font_color: String(current.font_color || region.font_color || defaultStyle.font_color || '#000000').trim() || '#000000'
};
}
function updateSchedulePlaceholderPanel(card) {
if (!card) {
return;
}
var regionId = card.getAttribute('data-region-id');
var panel = card.querySelector('[data-schedule-placeholder-chips] .d-flex.flex-wrap.gap-2');
if (!regionId || !panel) {
return;
}
var groups = getScheduleGroups();
var current = getCurrentSelection(regionId, card, {}, groups);
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
}
if (!window.__scheduleRegionPlaceholderRefreshInstalled) {
window.__scheduleRegionPlaceholderRefreshInstalled = true;
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
if (!target.closest('select[name^="region_schedule_group_id_"], select[name^="region_schedule_display_mode_"], input[name^="region_schedule_max_items_"]')) {
return;
}
var card = target.closest('[data-region-id]');
if (card) {
updateSchedulePlaceholderPanel(card);
}
}, true);
}
function buildEditorCardContext(context) {
return {
region: context.region,
current: context.current || {},
scheduleGroups: Array.isArray(context.scheduleGroups) ? context.scheduleGroups : []
};
}
function buildPreviewRenderContext(region, card, existingContent, scheduleGroups) {
var current = getCurrentConfig(region, existingContent || {});
var scheduleGroupInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]') : null;
var displayModeInput = card && card.querySelector ? card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]') : null;
var maxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]') : null;
var textAreaInput = card && card.querySelector ? card.querySelector('textarea.editor-source') : null;
var hiddenInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]') : null;
var editor = window.tinymce && typeof window.tinymce.get === 'function' ? window.tinymce.get('slide-editor-region-' + region.id) : null;
var value = String(editor ? editor.getContent({ format: 'html' }) : (hiddenInput && hiddenInput.value !== undefined ? hiddenInput.value : (textAreaInput && textAreaInput.value !== undefined ? textAreaInput.value : current.value)));
return {
value: value,
style: getTextStyle(region, current),
schedule_group_id: scheduleGroupInput ? scheduleGroupInput.value || current.schedule_group_id : current.schedule_group_id,
display_mode: displayModeInput ? displayModeInput.value || current.display_mode : current.display_mode,
max_items: maxItemsInput ? maxItemsInput.value || current.max_items : current.max_items,
existingContent: existingContent || {},
scheduleGroups: Array.isArray(scheduleGroups) ? scheduleGroups : []
};
}
function renderPreview(region, regionContent, context) {
var groups = context && context.scheduleGroups ? context.scheduleGroups : [];
var style = regionContent && regionContent.style ? regionContent.style : getTextStyle(region, regionContent || {});
var value = String(regionContent && (regionContent.value !== undefined ? regionContent.value : regionContent.text !== undefined ? regionContent.text : '') || '').trim();
var groupId = regionContent && regionContent.schedule_group_id !== undefined ? regionContent.schedule_group_id : '';
var displayMode = regionContent && regionContent.display_mode ? regionContent.display_mode : 'upcoming';
var maxItems = regionContent && regionContent.max_items !== undefined ? regionContent.max_items : 5;
var group = getGroupById(groupId, groups);
var entries = getVisibleEntries(groupId, displayMode, maxItems, groups);
if (!entries.length) {
entries = getEntries(groupId, groups).slice(0, Math.max(1, Number(maxItems || 5)));
}
if (!value) {
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '"></div>';
}
return '<div class="slide-preview-region schedule" style="width:100%;height:100%;overflow:hidden;' + (style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '') + (style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '') + (style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '') + '">' + entries.map(function (entry, index) {
return '<div class="schedule-region-entry" data-schedule-entry-index="' + index + '">' + sanitizeRichText(renderTemplate(value, Object.assign({}, entry || {}, {
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
group: group || {},
entries: entries,
index: index + 1
}))) + '</div>';
}).join('') + '</div>';
}
registry.register('schedule', {
label: 'Schedule',
getDefaultStyle: getDefaultStyle,
getDefaultRegionSize: function () {
return { width: 520, height: 280 };
},
getCurrentConfig: getCurrentConfig,
renderPreview: renderPreview,
renderEditorCard: renderEditorCard,
buildEditorCardContext: buildEditorCardContext,
buildPreviewRenderContext: buildPreviewRenderContext
});
}());
+81 -6
View File
@@ -2,7 +2,12 @@
(function () {
var root = window;
var transformPattern = /^(upper|lower|title)\(\)$/i;
var transformPattern = /^([a-z_][a-z0-9_]*)\((.*)\)$/i;
var monthNamesShort = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
var monthNamesLong = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
var dayNamesShort = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var dayNamesLong = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
function resolvePath(value, path) {
var current = value;
@@ -29,11 +34,15 @@
while (segments.length) {
var candidate = String(segments[segments.length - 1] || '').trim();
if (!transformPattern.test(candidate)) {
var match = candidate.match(transformPattern);
if (!match) {
break;
}
transforms.unshift(candidate.replace(/\(\)$/g, '').toLowerCase());
transforms.unshift({
name: String(match[1] || '').trim().toLowerCase(),
args: match[2] ? splitTransformArgs(match[2]) : []
});
segments.pop();
}
@@ -43,23 +52,89 @@
};
}
function splitTransformArgs(value) {
var source = String(value || '').trim();
if (!source) {
return [];
}
if ((source[0] === '"' && source[source.length - 1] === '"') || (source[0] === '\'' && source[source.length - 1] === '\'')) {
return [source.slice(1, -1)];
}
return source.split(',').map(function (item) {
return String(item || '').trim();
}).filter(Boolean);
}
function padNumber(value, size) {
var text = String(Math.abs(Number(value || 0)));
while (text.length < size) {
text = '0' + text;
}
return text;
}
function formatDateValue(value, pattern) {
var date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
var format = String(pattern || 'YYYY-MM-DD HH:mm').trim() || 'YYYY-MM-DD HH:mm';
var hours24 = date.getHours();
var hours12 = hours24 % 12 || 12;
var tokenMap = {
YYYY: String(date.getFullYear()),
YY: String(date.getFullYear()).slice(-2),
MMMM: monthNamesLong[date.getMonth()],
MMM: monthNamesShort[date.getMonth()],
MM: padNumber(date.getMonth() + 1, 2),
M: String(date.getMonth() + 1),
DD: padNumber(date.getDate(), 2),
D: String(date.getDate()),
dddd: dayNamesLong[date.getDay()],
ddd: dayNamesShort[date.getDay()],
HH: padNumber(hours24, 2),
H: String(hours24),
hh: padNumber(hours12, 2),
h: String(hours12),
mm: padNumber(date.getMinutes(), 2),
m: String(date.getMinutes()),
ss: padNumber(date.getSeconds(), 2),
s: String(date.getSeconds()),
A: hours24 >= 12 ? 'PM' : 'AM',
a: hours24 >= 12 ? 'pm' : 'am'
};
return format.replace(/\[([^\]]+)\]|YYYY|YY|MMMM|MMM|MM|M|DD|D|dddd|ddd|HH|H|hh|h|mm|m|ss|s|A|a/g, function (match, literal) {
return literal || tokenMap[match] || match;
});
}
function applyTransform(value, transform) {
var text = String(value === undefined || value === null ? '' : value);
var name = String(transform && transform.name || '').trim().toLowerCase();
var args = Array.isArray(transform && transform.args) ? transform.args : [];
if (transform === 'lower') {
if (name === 'lower') {
return text.toLowerCase();
}
if (transform === 'upper') {
if (name === 'upper') {
return text.toUpperCase();
}
if (transform === 'title') {
if (name === 'title') {
return text.toLowerCase().replace(/\b([a-z])/g, function (match, letter) {
return String(letter || '').toUpperCase();
});
}
if (name === 'format' || name === 'date' || name === 'datetime' || name === 'time') {
return formatDateValue(value, args[0] || (name === 'time' ? 'h:mm A' : name === 'date' ? 'MMM D, YYYY' : 'MMM D, YYYY h:mm A'));
}
return text;
}
+22 -3
View File
@@ -23,6 +23,22 @@ export function createSlideFormEditorController(options) {
return String(value === undefined || value === null ? '' : value).trim();
}
function isEmptyRichTextValue(value) {
var raw = String(value === undefined || value === null ? '' : value).trim();
if (!raw) {
return true;
}
var stripped = raw
.replace(/<\s*br\s*\/?>/gi, '')
.replace(/<p[^>]*>(?:\s|&nbsp;|<br\s*\/?>)*<\/p>/gi, '')
.replace(/<[^>]+>/g, '')
.replace(/&nbsp;/gi, '')
.trim();
return !stripped;
}
function normalizeFontSizeValue(value) {
var raw = String(value || '').trim().toLowerCase();
if (/^\d+(?:\.\d+)?px$/.test(raw)) {
@@ -156,6 +172,7 @@ export function createSlideFormEditorController(options) {
editor.save();
}
var content = editor.getContent({ format: 'html' });
content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) {
hidden.value = content;
}
@@ -170,6 +187,7 @@ export function createSlideFormEditorController(options) {
editor.on('init', function () {
var content = editor.getContent({ format: 'html' });
content = isEmptyRichTextValue(content) ? '' : content;
if (hidden) {
hidden.value = content;
}
@@ -215,20 +233,21 @@ export function createSlideFormEditorController(options) {
promotion: false,
statusbar: true,
resize: true,
plugins: 'lists link code advlist fullscreen',
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist | fullscreen',
plugins: 'lists link code advlist fullscreen table',
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist table | fullscreen',
toolbar_mode: 'sliding',
license_key: 'gpl',
skin: themeAssets.skinName,
skin_url: themeAssets.skinUrl,
content_css: getContentCss(),
body_class: themeAssets.bodyClass,
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
content_style: 'body { font-family: ' + defaultEditorFontFamily + '; font-size: 32px; line-height: 1.5; background-color: ' + getEditorBackgroundColorValue() + '; } p { margin: 1em 0; } p:first-child { margin-top: 0; } p:last-child { margin-bottom: 1em; } table { border-collapse: collapse; width: 100%; } td, th { border: 1px solid currentColor; padding: 0.35em 0.5em; vertical-align: top; } th { font-weight: 700; }' + (editorContentStyle ? ' ' + editorContentStyle : ''),
font_family_formats: getFontFamilyFormats(),
font_size_input_default_unit: 'px',
forced_root_block: 'p',
force_br_newlines: false,
newline_behavior: 'default',
placeholder: String(source.getAttribute('placeholder') || '').trim(),
setup: function (editor) {
editorInstances.set(regionId, editor);
attachEditorEvents(regionId, editor);
+32 -8
View File
@@ -8,6 +8,7 @@ export function createSlideFormRegionHelpers(options) {
var existingContent = settings.existingContent || {};
var rssFeeds = Array.isArray(settings.rssFeeds) ? settings.rssFeeds : [];
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
var scheduleGroups = Array.isArray(settings.scheduleGroups) ? settings.scheduleGroups : [];
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
var placeholderUtils = window.placeholderUtils || {};
var placeholderChips = window.placeholderChips || {};
@@ -459,6 +460,15 @@ export function createSlideFormRegionHelpers(options) {
};
}
function getCurrentScheduleConfig(region) {
var current = existingContent[region.region_key] || {};
return {
schedule_group_id: current.schedule_group_id === undefined || current.schedule_group_id === null || current.schedule_group_id === '' ? '' : Number(current.schedule_group_id),
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
max_items: Math.max(1, Number(current.max_items || 5))
};
}
function getCurrentRegionVideoDuration(region) {
var current = existingContent[region.region_key] || {};
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000;
@@ -470,13 +480,13 @@ export function createSlideFormRegionHelpers(options) {
if (module && typeof module.buildEditorCardContext === 'function') {
return module.buildEditorCardContext({
region: region,
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'schedule' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
currentDuration: getCurrentRegionVideoDuration(region),
regionRatio: reduceAspectRatio(region.width, region.height),
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: (existingContent[region.region_key] || {}).disable_audio,
config: region.region_type === 'api' ? getCurrentApiConfig(region) : getCurrentRssConfig(region),
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : getCurrentRssConfig(region),
style: getCurrentTextStyle(region),
fontSize: getCurrentTextStyle(region).font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -492,8 +502,9 @@ export function createSlideFormRegionHelpers(options) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).items_path), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path)
}, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
sampleDataPreview: buildApiSampleDataMarkup(getCurrentApiConfig(region).source_id, getCurrentApiConfig(region).item_number, getCurrentApiConfig(region).items_path),
scheduleGroups: scheduleGroups
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -510,7 +521,7 @@ export function createSlideFormRegionHelpers(options) {
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
disableAudio: currentContent.disable_audio,
config: region.region_type === 'api' ? apiConfig : rssConfig,
config: region.region_type === 'api' ? apiConfig : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : rssConfig,
style: textStyle,
fontSize: textStyle.font_size,
feedOptions: rssFeeds.map(function (feed) {
@@ -526,7 +537,8 @@ export function createSlideFormRegionHelpers(options) {
return '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
}).join('')
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath)
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
scheduleGroups: scheduleGroups
};
}
@@ -542,7 +554,7 @@ export function createSlideFormRegionHelpers(options) {
function getPreviewRegionContent(card, region) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
}
var current = existingContent[region.region_key];
@@ -592,6 +604,14 @@ export function createSlideFormRegionHelpers(options) {
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
content.item_number = apiItemInput ? apiItemInput.value : content.item_number;
content.items_path = apiItemsPathInput ? apiItemsPathInput.value : content.items_path;
} else if (region.region_type === 'schedule') {
var scheduleGroupInput = card.querySelector('select[name="region_schedule_group_id_' + region.id + '"]');
var scheduleDisplayModeInput = card.querySelector('select[name="region_schedule_display_mode_' + region.id + '"]');
var scheduleMaxItemsInput = card.querySelector('input[name="region_schedule_max_items_' + region.id + '"]');
var currentSchedule = getCurrentScheduleConfig(region);
content.schedule_group_id = scheduleGroupInput ? scheduleGroupInput.value : currentSchedule.schedule_group_id;
content.display_mode = scheduleDisplayModeInput ? scheduleDisplayModeInput.value : currentSchedule.display_mode;
content.max_items = scheduleMaxItemsInput ? scheduleMaxItemsInput.value : currentSchedule.max_items;
} else {
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
}
@@ -602,7 +622,7 @@ export function createSlideFormRegionHelpers(options) {
function buildPreviewRenderContext(region, card) {
var module = getRegionTypeModule(region.region_type);
if (module && typeof module.buildPreviewRenderContext === 'function') {
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : apiSources);
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
}
var textStyle = getCurrentTextStyle(region);
@@ -619,6 +639,10 @@ export function createSlideFormRegionHelpers(options) {
item_number: previewContent.item_number,
source_id: previewContent.source_id,
items_path: previewContent.items_path,
schedule_group_id: previewContent.schedule_group_id,
display_mode: previewContent.display_mode,
max_items: previewContent.max_items,
scheduleGroups: scheduleGroups,
font_size: textStyle.font_size,
font_color: textStyle.font_color,
font_family: textStyle.font_family
+6 -1
View File
@@ -21,6 +21,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
var rssFeeds = Array.isArray(slideEditorData.rssFeeds) ? slideEditorData.rssFeeds : [];
var apiSources = Array.isArray(slideEditorData.apiSources) ? slideEditorData.apiSources : [];
var scheduleGroups = Array.isArray(slideEditorData.scheduleGroups) ? slideEditorData.scheduleGroups : [];
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
var existingContent = slideEditorData.existingContent || {};
@@ -49,7 +50,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
var currentPreviewCanvasWidth = 0;
var currentPreviewCanvasHeight = 0;
var sidebarSyncFrame = 0;
var sidebarTopOffset = 16;
var sidebarBaseOffset = 16;
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
var regionTypes = window.pulseRegionTypes || {};
@@ -119,6 +120,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
existingContent: existingContent,
rssFeeds: rssFeeds,
apiSources: apiSources,
scheduleGroups: scheduleGroups,
defaultFontSize: DEFAULT_FONT_SIZE,
uploadMaxLabel: uploadMaxLabel,
uploadVideoMaxLabel: uploadVideoMaxLabel,
@@ -487,6 +489,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
return;
}
var stickyHeader = document.querySelector('.app-header');
var stickyHeaderHeight = stickyHeader ? Math.max(0, Math.round(stickyHeader.getBoundingClientRect().height || stickyHeader.offsetHeight || 0)) : 0;
var sidebarTopOffset = sidebarBaseOffset + stickyHeaderHeight + 12;
var shellRect = slideEditorShell.getBoundingClientRect();
var sidebarHeight = slideEditorSidebar.offsetHeight;
var shellTop = window.scrollY + shellRect.top;
+4 -2
View File
@@ -274,6 +274,7 @@ module.exports = function registerContentRoutes(app, deps) {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -287,7 +288,7 @@ module.exports = function registerContentRoutes(app, deps) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'create', null, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
@@ -304,6 +305,7 @@ module.exports = function registerContentRoutes(app, deps) {
const data = await common.fetchTemplatesData(pool);
const rssData = await common.fetchRssFeedsData(pool);
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
const scheduleData = typeof common.fetchSchedulesData === 'function' ? await common.fetchSchedulesData(pool) : { scheduleGroups: [] };
const apiSources = Array.isArray(apiData.apiSources) ? apiData.apiSources.map(function (source) {
return Object.assign({}, source, {
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
@@ -317,7 +319,7 @@ module.exports = function registerContentRoutes(app, deps) {
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
}) });
}));
Object.assign(data, rssData, apiData, { rssFeeds: rssFeeds, apiSources: apiSources, fontLibrary: loadFontLibrary(deps.uploadDir) });
Object.assign(data, rssData, apiData, scheduleData, { rssFeeds: rssFeeds, apiSources: apiSources, scheduleGroups: scheduleData.scheduleGroups || [], fontLibrary: loadFontLibrary(deps.uploadDir) });
res.send(pages.renderSlideFormPage(data, 'edit', slide, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
+291 -2
View File
@@ -13,6 +13,7 @@ module.exports = function registerDataSourceRoutes(app, deps) {
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
const requirePermission = deps.requirePermission;
const parseDateTimeLocal = deps.parseDateTimeLocal;
const { buildPagination } = require('#src/web/lib/pagination');
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
@@ -73,6 +74,7 @@ module.exports = function registerDataSourceRoutes(app, deps) {
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
const apiSourceIds = new Set();
const rssFeedIds = new Set();
const scheduleGroupIds = new Set();
for (let index = 0; index < slides.length; index += 1) {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
@@ -104,6 +106,13 @@ module.exports = function registerDataSourceRoutes(app, deps) {
}
}
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
const scheduleGroupId = Number(value.schedule_group_id);
if (Number.isFinite(scheduleGroupId)) {
scheduleGroupIds.add(scheduleGroupId);
}
}
Object.keys(value).forEach(function (key) {
walk(value[key]);
});
@@ -112,10 +121,28 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return {
apiSourceIds: apiSourceIds,
rssFeedIds: rssFeedIds
rssFeedIds: rssFeedIds,
scheduleGroupIds: scheduleGroupIds
};
}
function readScheduleArrayField(body, key) {
if (!body || !Object.prototype.hasOwnProperty.call(body, key)) {
return [];
}
const value = body[key];
if (Array.isArray(value)) {
return value;
}
if (value === undefined || value === null) {
return [];
}
return [value];
}
if (!pool || !common || !pages || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof requirePermission !== 'function' || !backgroundTaskQueue) {
throw new Error('registerDataSourceRoutes requires the data source route dependencies.');
}
@@ -150,7 +177,10 @@ module.exports = function registerDataSourceRoutes(app, deps) {
return res.redirect('/login');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['schedules.read', 'rss-feeds.read', 'api-sources.read'])) {
if (hasAnyPermission(req.currentUser, ['schedules.read'])) {
return res.redirect('/data-sources/schedules');
}
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
return res.redirect('/data-sources/rss-feeds');
}
@@ -560,4 +590,263 @@ module.exports = function registerDataSourceRoutes(app, deps) {
next(error);
}
});
app.get('/data-sources/schedules', requirePermission('schedules.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);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchScheduleGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const usageMaps = await getDataSourceUsageMaps();
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
inUse: usageMaps.scheduleGroupIds.has(Number(scheduleGroup.id))
});
});
res.send(pages.renderScheduleGroupsPage({
scheduleGroups: scheduleGroups,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Schedules', 'Schedule groups')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) {
next(error);
}
});
app.get('/data-sources/schedules/new', requirePermission('schedules.create'), function (req, res) {
res.send(pages.renderScheduleGroupFormPage(null, [], 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
});
function readScheduleArrayFieldValue(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
for (let index = 0; index < searchKeys.length; index += 1) {
const key = searchKeys[index];
const value = body && Object.prototype.hasOwnProperty.call(body, key) ? body[key] : undefined;
if (Array.isArray(value)) {
return value;
}
if (value !== undefined && value !== null) {
return [value];
}
}
return [];
}
function buildScheduleEntryRows(req) {
const body = req.body || {};
const ids = readScheduleArrayFieldValue(body, ['entry_id[]', 'entry_id']);
const titles = readScheduleArrayFieldValue(body, ['entry_title[]', 'entry_title']);
const descriptions = readScheduleArrayFieldValue(body, ['entry_short_description[]', 'entry_short_description']);
const starts = readScheduleArrayFieldValue(body, ['entry_start_datetime[]', 'entry_start_datetime']);
const ends = readScheduleArrayFieldValue(body, ['entry_end_datetime[]', 'entry_end_datetime']);
const lengths = [ids.length, titles.length, descriptions.length, starts.length, ends.length].filter(Boolean);
if (lengths.length && lengths.some(function (value) { return value !== lengths[0]; })) {
const error = new Error('Schedule entry data is invalid.');
error.statusCode = 400;
throw error;
}
const rows = [];
for (let index = 0; index < titles.length; index += 1) {
const idValue = Number(ids[index]);
const title = String(titles[index] || '').trim();
const shortDescription = String(descriptions[index] || '').trim();
const startDatetime = parseDateTimeLocal(starts[index]);
const endDatetime = parseDateTimeLocal(ends[index]);
const hasContent = title || shortDescription || String(starts[index] || '').trim() || String(ends[index] || '').trim() || Number.isFinite(idValue);
if (!hasContent) {
continue;
}
if (!title) {
const error = new Error('Each schedule entry requires a title.');
error.statusCode = 400;
throw error;
}
if (!startDatetime) {
const error = new Error('Each schedule entry requires a start datetime.');
error.statusCode = 400;
throw error;
}
if (endDatetime && endDatetime < startDatetime) {
const error = new Error('Schedule entry end datetime must be after the start datetime.');
error.statusCode = 400;
throw error;
}
rows.push({
id: Number.isFinite(idValue) && idValue > 0 ? idValue : null,
title: title,
shortDescription: shortDescription,
startDatetime: startDatetime,
endDatetime: endDatetime
});
}
return rows;
}
app.post('/data-sources/schedules', requirePermission('schedules.create'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const payload = common.buildScheduleGroupPayload(req, null);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
return res.redirect('/data-sources/schedules/new?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
const [result] = await connection.query(
'INSERT INTO i_schedule_groups (name, short_description, created_by, modified_by) VALUES (?, ?, ?, ?)',
[payload.name, payload.shortDescription || null, actorId, actorId]
);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
result.insertId,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
redirectAfterSave(req, res, '/data-sources/schedules/' + result.insertId + '/edit', {
closeUrl: '/data-sources/schedules',
newUrl: '/data-sources/schedules/new',
message: 'Schedule group created.'
});
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.get('/data-sources/schedules/:id/edit', requirePermission('schedules.update'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const inUse = await getDataSourceInUse('schedule_group_id', scheduleGroup.id);
const scheduleEntries = typeof common.fetchScheduleEntriesByGroupId === 'function'
? await common.fetchScheduleEntriesByGroupId(pool, scheduleGroup.id)
: [];
res.send(pages.renderScheduleGroupFormPage(Object.assign({}, scheduleGroup, {
shortDescription: scheduleGroup.short_description || '',
inUse: inUse
}), scheduleEntries, 'edit', req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.post('/data-sources/schedules/:id', requirePermission('schedules.update'), async function (req, res, next) {
const connection = await pool.getConnection();
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const payload = common.buildScheduleGroupPayload(req, scheduleGroup);
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, scheduleGroup.id)) {
return res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent('A schedule group with that name already exists.'));
}
const entryRows = buildScheduleEntryRows(req);
const actorId = getAuditUserId(req);
await connection.beginTransaction();
await connection.query(
'UPDATE i_schedule_groups SET name = ?, short_description = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.shortDescription || null, actorId, scheduleGroup.id]
);
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [scheduleGroup.id]);
if (entryRows.length) {
const insertRows = entryRows.map(function (entry) {
return [
scheduleGroup.id,
entry.title,
entry.shortDescription || null,
entry.startDatetime,
entry.endDatetime || null,
actorId,
actorId
];
});
await connection.query(
'INSERT INTO i_schedule_entries (schedule_group_id, title, short_description, start_datetime, end_datetime, created_by, modified_by) VALUES ?',
[insertRows]
);
}
await connection.commit();
const message = 'Schedule group updated.';
res.redirect('/data-sources/schedules/' + scheduleGroup.id + '/edit?message=' + encodeURIComponent(message));
} catch (error) {
try {
await connection.rollback();
} catch (_rollbackError) {
// Ignore rollback failures and surface the original error.
}
next(error);
} finally {
connection.release();
}
});
app.post('/data-sources/schedules/:id/delete', requirePermission('schedules.delete'), async function (req, res, next) {
try {
const scheduleGroup = await common.fetchScheduleGroupById(pool, Number(req.params.id));
if (!scheduleGroup) {
return res.status(404).send('Schedule group not found');
}
const blockMessage = await getDataSourceDeleteBlockMessage('schedule_group_id', scheduleGroup.id, 'schedule group');
if (blockMessage) {
return res.redirect('/data-sources/schedules?message=' + encodeURIComponent(blockMessage));
}
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [scheduleGroup.id]);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/data-sources/schedules?message=' + encodeURIComponent('Schedule group deleted.'));
} catch (error) {
next(error);
}
});
};
@@ -0,0 +1,54 @@
// Schedule group add/edit renderer and defaults.
const { renderView } = require('../../../view');
function buildDefaultScheduleGroup() {
return {
id: null,
name: '',
shortDescription: ''
};
}
function formatDateTimeLocalValue(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = String(date.getFullYear()).padStart(4, '0');
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}T${hours}:${minutes}`;
}
module.exports = function renderScheduleGroupFormPage(scheduleGroup, scheduleEntries, mode, message, currentUser) {
const isEdit = mode === 'edit';
const viewScheduleGroup = Object.assign(buildDefaultScheduleGroup(), scheduleGroup || {});
const viewScheduleEntries = Array.isArray(scheduleEntries) && scheduleEntries.length
? scheduleEntries.map(function (entry) {
return Object.assign({}, entry, {
startValue: formatDateTimeLocalValue(entry.start_datetime),
endValue: formatDateTimeLocalValue(entry.end_datetime)
});
})
: [{ id: null, title: '', short_description: '', startValue: '', endValue: '', sort_order: 0 }];
return renderView('data-sources/schedules/form', {
title: isEdit ? 'Edit schedule group' : 'Add schedule group',
active: 'schedules',
message: message,
currentUser: currentUser || null,
isEdit: isEdit,
scheduleGroup: viewScheduleGroup,
scheduleEntries: viewScheduleEntries,
inUse: Boolean(viewScheduleGroup.inUse),
assetVersion: Date.now().toString(36)
});
};
@@ -0,0 +1,32 @@
// Schedule group list page renderer.
const { renderView } = require('../../../view');
function formatNextStartLabel(value, formatDashboardDate) {
if (!value) {
return 'No entries';
}
const label = typeof formatDashboardDate === 'function'
? formatDashboardDate(value)
: String(value);
return label || 'No entries';
}
module.exports = function renderScheduleGroupsPage(data, message, currentUser, formatDashboardDate) {
const scheduleGroups = (data.scheduleGroups || []).map(function (scheduleGroup) {
return Object.assign({}, scheduleGroup, {
nextStartLabel: formatNextStartLabel(scheduleGroup.next_start_datetime, formatDashboardDate),
nextStartValue: scheduleGroup.next_start_datetime ? new Date(scheduleGroup.next_start_datetime).toISOString() : ''
});
});
return renderView('data-sources/schedules/list', {
title: 'Schedules',
active: 'schedules',
message: message,
currentUser: currentUser || null,
scheduleGroups: scheduleGroups,
pagination: data.pagination || null
});
};
+2
View File
@@ -15,6 +15,8 @@ module.exports = {
renderApiSourcesPage: require('./data-sources/api-sources/list'),
renderApiSourceFormPage: require('./data-sources/api-sources/add'),
renderApiSourceEditPage: require('./data-sources/api-sources/edit'),
renderScheduleGroupsPage: require('./data-sources/schedules/list'),
renderScheduleGroupFormPage: require('./data-sources/schedules/form'),
renderRssFeedsPage: require('./data-sources/rss-feeds/list'),
renderRssFeedFormPage: require('./data-sources/rss-feeds/add'),
renderRssFeedEditPage: require('./data-sources/rss-feeds/edit'),
+1
View File
@@ -168,6 +168,7 @@ function registerSettingsAndContentRoutes(app, deps) {
formatDashboardDate: deps.formatDashboardDate,
getAuditUserId: deps.getAuditUserId,
redirectAfterSave: deps.redirectAfterSave,
parseDateTimeLocal: deps.parseDateTimeLocal,
backgroundTaskQueue: deps.backgroundTaskQueue,
dataSourceTasks: deps.dataSourceTasks,
setAuthMessageCookie: deps.setAuthMessageCookie,
+2
View File
@@ -8,6 +8,7 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
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,
@@ -30,6 +31,7 @@ module.exports = function renderSlideFormPage(data, mode, slide, message, curren
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,
@@ -0,0 +1,111 @@
<div class="page-header">
<div>
<h2>{{#if isEdit}}Edit schedule group{{else}}Add schedule group{{/if}}</h2>
<p>Define a group of events with their start and end times.</p>
</div>
</div>
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">Schedule group details</h3>
</div>
<form id="schedule-group-form" method="post" action="{{#if isEdit}}/data-sources/schedules/{{scheduleGroup.id}}{{else}}/data-sources/schedules{{/if}}" data-async-save data-async-save-close-url="/data-sources/schedules" data-async-save-new-url="/data-sources/schedules/new">
<div class="card-body d-grid gap-4">
<div class="row g-3">
<div class="col-12 col-lg-4">
<label for="schedule-group-name" class="form-label">Name</label>
<input id="schedule-group-name" name="name" class="form-control" value="{{scheduleGroup.name}}" required />
</div>
<div class="col-12 col-lg-8">
<label for="schedule-group-short-description" class="form-label">Short description</label>
<input id="schedule-group-short-description" name="short_description" class="form-control" value="{{scheduleGroup.shortDescription}}" placeholder="Morning events" />
</div>
</div>
<div>
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
<h4 class="schedule-section-heading mb-0">Entries</h4>
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-schedule-entry>Add entry</button>
</div>
<div class="table-responsive">
<table class="table table-bordered align-middle schedule-entries-table mb-0">
<thead>
<tr>
<th style="width: 18rem;">Title</th>
<th>Description</th>
<th style="width: 12rem;">Start</th>
<th style="width: 12rem;">End</th>
<th style="width: 6rem;">Actions</th>
</tr>
</thead>
<tbody data-schedule-entries-body>
{{#each scheduleEntries}}
<tr data-schedule-entry-row>
<td>
<input type="hidden" name="entry_id[]" value="{{id}}" />
<input type="text" name="entry_title[]" class="form-control" value="{{title}}" required />
</td>
<td>
<input type="text" name="entry_short_description[]" class="form-control" value="{{short_description}}" placeholder="Optional description" />
</td>
<td>
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="{{startValue}}" required />
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-schedule-entry>Remove</button>
</td>
</tr>
{{/each}}
</tbody>
</table>
</div>
<p class="form-text mb-0">Add one or more rows. A schedule region can render upcoming or current entries from this group.</p>
</div>
</div>
<div class="card-footer d-flex justify-content-end">
<div class="btn-group" role="group" aria-label="Schedule group actions">
{{{saveActionButtons formId="schedule-group-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/data-sources/schedules" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
{{#if isEdit}}
{{#if inUse}}
<button type="submit" class="btn btn-outline-danger" form="delete-schedule-group-form" disabled>Delete</button>
{{else}}
<button type="submit" class="btn btn-danger" form="delete-schedule-group-form">Delete</button>
{{/if}}
{{else}}
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
{{/if}}
</div>
</div>
</form>
</div>
<template id="schedule-entry-row-template">
<tr data-schedule-entry-row>
<td>
<input type="hidden" name="entry_id[]" value="" />
<input type="text" name="entry_title[]" class="form-control" value="" required />
</td>
<td>
<input type="text" name="entry_short_description[]" class="form-control" value="" placeholder="Optional description" />
</td>
<td>
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="" required />
</td>
<td>
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" />
</td>
<td>
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-schedule-entry>Remove</button>
</td>
</tr>
</template>
{{#if isEdit}}
<form id="delete-schedule-group-form" method="post" action="/data-sources/schedules/{{scheduleGroup.id}}/delete" data-confirm-message="Delete this schedule group?"></form>
{{/if}}
<script type="module" src="/assets/js/data-sources/schedule-group-form.js?v={{assetVersion}}"></script>
@@ -0,0 +1,75 @@
<div class="page-header">
<div>
<h2>Schedules</h2>
<p>Store grouped events with start and end times so a schedule region can show what is coming up next.</p>
</div>
</div>
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
<div class="card-header">
<h3 class="card-title">Saved groups</h3>
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search schedules" aria-label="Search schedules" data-table-search />
</div>
{{#if (hasPermission currentUser 'schedules.create')}}
<a class="btn btn-primary btn-sm" href="/data-sources/schedules/new">Add schedule group</a>
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th data-table-sort-key="name">Name</th>
<th data-table-sort-key="description">Description</th>
<th data-table-sort-key="entries">Entries</th>
<th data-table-sort-key="next_start">Next start</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if scheduleGroups.length}}
{{#each scheduleGroups}}
<tr data-table-search-row>
<td data-label="Name">{{name}}</td>
<td data-label="Description" class="text-break">{{short_description}}</td>
<td data-label="Entries">{{entry_count}}</td>
<td data-label="Next start">
{{#if nextStartValue}}
<time data-local-datetime datetime="{{nextStartValue}}">{{nextStartLabel}}</time>
{{else}}
{{nextStartLabel}}
{{/if}}
</td>
<td data-label="Actions">
{{#if (anyPermission ../currentUser 'schedules.update' 'schedules.delete')}}
<div class="actions">
{{#if (hasPermission ../currentUser 'schedules.update')}}
<a class="btn btn-sm btn-primary" href="/data-sources/schedules/{{id}}/edit">Edit</a>
{{/if}}
{{#if (hasPermission ../currentUser 'schedules.delete')}}
<form class="inline-form" method="post" action="/data-sources/schedules/{{id}}/delete" data-confirm-message="Delete this schedule group?">
{{#if inUse}}
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
{{else}}
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
{{/if}}
</form>
{{/if}}
</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default><td colspan="5" class="empty">No schedule groups yet.</td></tr>
{{/if}}
</tbody>
</table>
</div>
{{> table-pagination pagination=pagination basePath="/data-sources/schedules" alwaysShow=true}}
</div>
+9 -1
View File
@@ -215,7 +215,7 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'rss-feeds.read' 'api-sources.read')}}
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}}
<li class="nav-header">DATA SOURCES</li>
{{#if (hasPermission currentUser 'rss-feeds.read')}}
<li class="nav-item">
@@ -225,6 +225,14 @@
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'schedules.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'schedules')}}active{{/if}}" href="/data-sources/schedules">
<i class="nav-icon bi bi-calendar-event"></i>
<p>Schedules</p>
</a>
</li>
{{/if}}
{{#if (hasPermission currentUser 'api-sources.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'api-sources')}}active{{/if}}" href="/data-sources/api-sources">