From 2b9cabdab2dac63172fe84661a68f6c6dd19f617 Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sun, 2 Aug 2026 14:04:41 +0100 Subject: [PATCH] Implement schedule WYSIWYG and UTC dates --- src/common.js | 6 + src/data/index.js | 7 + src/data/schedules.js | 120 +++++++ src/db/common.js | 1 + src/db/index.js | 29 ++ src/player/playlist.js | 18 +- src/player/public/css/player.css | 40 +-- src/player/public/sw.js | 21 +- src/player/regions/schedule.js | 246 +++++++++++++ src/player/render.js | 2 +- src/player/routes.js | 5 + src/player/thumbnail-preview.js | 1 + src/rbac.js | 12 + src/web/pages/index.js | 2 + src/web/public/css/theme-custom.css | 9 + .../js/data-sources/schedule-group-form.js | 35 ++ src/web/public/js/regions/type/schedule.js | 338 ++++++++++++++++++ src/web/public/js/shared/placeholder-utils.js | 87 ++++- src/web/public/js/slides/slide-form-editor.js | 25 +- .../public/js/slides/slide-form-regions.js | 40 ++- src/web/public/js/slides/slide-form.js | 7 +- src/web/routes/admin/content.js | 6 +- src/web/routes/admin/data-sources.js | 293 ++++++++++++++- src/web/routes/data-sources/schedules/form.js | 54 +++ src/web/routes/data-sources/schedules/list.js | 32 ++ src/web/routes/index.js | 2 + src/web/routes/register.js | 1 + src/web/routes/signage/slides/form.js | 2 + src/web/views/data-sources/schedules/form.hbs | 111 ++++++ src/web/views/data-sources/schedules/list.hbs | 75 ++++ src/web/views/shared/layout.hbs | 10 +- 31 files changed, 1585 insertions(+), 52 deletions(-) create mode 100644 src/data/schedules.js create mode 100644 src/player/regions/schedule.js create mode 100644 src/web/public/js/data-sources/schedule-group-form.js create mode 100644 src/web/public/js/regions/type/schedule.js create mode 100644 src/web/routes/data-sources/schedules/form.js create mode 100644 src/web/routes/data-sources/schedules/list.js create mode 100644 src/web/views/data-sources/schedules/form.hbs create mode 100644 src/web/views/data-sources/schedules/list.hbs diff --git a/src/common.js b/src/common.js index f53fd6d..bdd3755 100644 --- a/src/common.js +++ b/src/common.js @@ -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, diff --git a/src/data/index.js b/src/data/index.js index c34161c..facd778 100644 --- a/src/data/index.js +++ b/src/data/index.js @@ -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, diff --git a/src/data/schedules.js b/src/data/schedules.js new file mode 100644 index 0000000..79217b0 --- /dev/null +++ b/src/data/schedules.js @@ -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 +}; \ No newline at end of file diff --git a/src/db/common.js b/src/db/common.js index f1b25ea..40b5040 100644 --- a/src/db/common.js +++ b/src/db/common.js @@ -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 diff --git a/src/db/index.js b/src/db/index.js index 191e5b1..1ea2f1b 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -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, diff --git a/src/player/playlist.js b/src/player/playlist.js index b6ed251..9bc22b3 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -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'); } diff --git a/src/player/public/css/player.css b/src/player/public/css/player.css index d1f35af..66fd1b2 100644 --- a/src/player/public/css/player.css +++ b/src/player/public/css/player.css @@ -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; +} diff --git a/src/player/public/sw.js b/src/player/public/sw.js index 899d9a8..459c841 100644 --- a/src/player/public/sw.js +++ b/src/player/public/sw.js @@ -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; diff --git a/src/player/regions/schedule.js b/src/player/regions/schedule.js new file mode 100644 index 0000000..f948a14 --- /dev/null +++ b/src/player/regions/schedule.js @@ -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, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); +} + +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(//gi, ''); + output = output.replace(//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 ''; + } + 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 '
'; + } + + return '
' + entries.map(function (entry, index) { + return '
' + 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 + }))) + '
'; + }).join('') + '
'; +} + +registry.register('schedule', { + renderRegion: renderRegion +}); \ No newline at end of file diff --git a/src/player/render.js b/src/player/render.js index a277d75..bf8302c 100644 --- a/src/player/render.js +++ b/src/player/render.js @@ -27,7 +27,7 @@ function getPlayerServiceWorkerRegistrationScript() { ' \ No newline at end of file diff --git a/src/web/views/data-sources/schedules/list.hbs b/src/web/views/data-sources/schedules/list.hbs new file mode 100644 index 0000000..91836a2 --- /dev/null +++ b/src/web/views/data-sources/schedules/list.hbs @@ -0,0 +1,75 @@ + + +
+
+

Saved groups

+
+
+ + +
+ {{#if (hasPermission currentUser 'schedules.create')}} + Add schedule group + {{/if}} +
+
+
+ + + + + + + + + + + + {{#if scheduleGroups.length}} + {{#each scheduleGroups}} + + + + + + + + {{/each}} + {{else}} + + {{/if}} + +
NameDescriptionEntriesNext startActions
{{name}}{{short_description}}{{entry_count}} + {{#if nextStartValue}} + + {{else}} + {{nextStartLabel}} + {{/if}} + + {{#if (anyPermission ../currentUser 'schedules.update' 'schedules.delete')}} +
+ {{#if (hasPermission ../currentUser 'schedules.update')}} + Edit + {{/if}} + {{#if (hasPermission ../currentUser 'schedules.delete')}} +
+ {{#if inUse}} + + {{else}} + + {{/if}} +
+ {{/if}} +
+ {{else}} + - + {{/if}} +
No schedule groups yet.
+
+ {{> table-pagination pagination=pagination basePath="/data-sources/schedules" alwaysShow=true}} +
\ No newline at end of file diff --git a/src/web/views/shared/layout.hbs b/src/web/views/shared/layout.hbs index 4439b32..9210f06 100644 --- a/src/web/views/shared/layout.hbs +++ b/src/web/views/shared/layout.hbs @@ -215,7 +215,7 @@ {{/if}} - {{#if (anyPermission currentUser 'rss-feeds.read' 'api-sources.read')}} + {{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}} {{#if (hasPermission currentUser 'rss-feeds.read')}} {{/if}} + {{#if (hasPermission currentUser 'schedules.read')}} + + {{/if}} {{#if (hasPermission currentUser 'api-sources.read')}}