Release v2.4.2
This commit is contained in:
+8
-5
@@ -27,6 +27,7 @@ const dbBootstrap = require('#src/db/bootstrap');
|
||||
const data = require('#src/data');
|
||||
const player = require('#src/player/render');
|
||||
const listQuery = require('#src/web/lib/list-query');
|
||||
const { fetchPlaylistCanvasId, fetchPlaylistCanvasSignature } = require('#src/web/lib/helpers');
|
||||
|
||||
module.exports = {
|
||||
createPool: dbCommon.createPool,
|
||||
@@ -59,12 +60,14 @@ module.exports = {
|
||||
getSortQuery: listQuery.getSortQuery,
|
||||
getSortDirectionQuery: listQuery.getSortDirectionQuery,
|
||||
fetchPlaylistById: data.fetchPlaylistById,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
normalizeDisplayMode: data.normalizeDisplayMode,
|
||||
fetchSchedulesData: data.fetchSchedulesData,
|
||||
fetchScheduleGroupsPage: data.fetchScheduleGroupsPage,
|
||||
fetchScheduleGroupById: data.fetchScheduleGroupById,
|
||||
fetchScheduleEntriesByGroupId: data.fetchScheduleEntriesByGroupId,
|
||||
buildScheduleGroupPayload: data.buildScheduleGroupPayload,
|
||||
fetchTimetablesData: data.fetchTimetablesData,
|
||||
fetchTimetableGroupsPage: data.fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById: data.fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId: data.fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload: data.buildTimetableGroupPayload,
|
||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||
fetchApiSourceById: data.fetchApiSourceById,
|
||||
|
||||
+24
-3
@@ -14,7 +14,7 @@ async function fetchAdminData(pool) {
|
||||
`);
|
||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height,
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height,
|
||||
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
@@ -28,14 +28,35 @@ async function fetchAdminData(pool) {
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.disable_audio, sl.id AS slide_id, sl.title, sl.content_json, sl.thumbnail_path, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
|
||||
`);
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||
const [playlistScheduleRules] = await pool.query(`
|
||||
SELECT id, playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_at, modified_at, created_by, modified_by
|
||||
FROM c_playlist_slide_schedule_rules
|
||||
ORDER BY playlist_slide_id ASC, position ASC, id ASC
|
||||
`);
|
||||
|
||||
const rulesBySlideId = new Map();
|
||||
playlistScheduleRules.forEach(function (rule) {
|
||||
const slideId = Number(rule.playlist_slide_id);
|
||||
if (!rulesBySlideId.has(slideId)) {
|
||||
rulesBySlideId.set(slideId, []);
|
||||
}
|
||||
rulesBySlideId.get(slideId).push(rule);
|
||||
});
|
||||
|
||||
const playlistSlidesWithRules = playlistSlides.map(function (slide) {
|
||||
return Object.assign({}, slide, {
|
||||
scheduleRules: rulesBySlideId.get(Number(slide.id)) || []
|
||||
});
|
||||
});
|
||||
|
||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides: playlistSlidesWithRules };
|
||||
}
|
||||
|
||||
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
|
||||
+6
-6
@@ -4,7 +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 { normalizeDisplayMode, fetchTimetablesData, fetchTimetableGroupsPage, fetchTimetableGroupById, fetchTimetableEntriesByGroupId, buildTimetableGroupPayload } = 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');
|
||||
@@ -38,11 +38,11 @@ module.exports = {
|
||||
buildAnnouncementPayload,
|
||||
fetchPlaylistById,
|
||||
normalizeDisplayMode,
|
||||
fetchSchedulesData,
|
||||
fetchScheduleGroupsPage,
|
||||
fetchScheduleGroupById,
|
||||
fetchScheduleEntriesByGroupId,
|
||||
buildScheduleGroupPayload,
|
||||
fetchTimetablesData,
|
||||
fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload,
|
||||
fetchApiSourcesData,
|
||||
fetchApiSourcesPage,
|
||||
fetchApiSourceById,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Playlist data access helpers.
|
||||
|
||||
async function fetchPlaylistById(pool, id) {
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
|
||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_at, modified_at, created_by, modified_by FROM c_playlists WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
|
||||
+21
-21
@@ -1,4 +1,4 @@
|
||||
// Schedule group and entry data access helpers.
|
||||
// Timetable group and entry data access helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
|
||||
@@ -10,22 +10,22 @@ function normalizeDisplayMode(value) {
|
||||
return 'upcoming';
|
||||
}
|
||||
|
||||
async function fetchSchedulesData(pool) {
|
||||
const [scheduleGroups] = await pool.query(`
|
||||
async function fetchTimetablesData(pool) {
|
||||
const [timetableGroups] = 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(`
|
||||
const [timetableEntries] = 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) {
|
||||
timetableEntries.forEach(function (entry) {
|
||||
const groupId = Number(entry.schedule_group_id);
|
||||
if (!entriesByGroupId.has(groupId)) {
|
||||
entriesByGroupId.set(groupId, []);
|
||||
@@ -33,19 +33,19 @@ async function fetchSchedulesData(pool) {
|
||||
entriesByGroupId.get(groupId).push(entry);
|
||||
});
|
||||
|
||||
const groups = scheduleGroups.map(function (group) {
|
||||
const groups = timetableGroups.map(function (group) {
|
||||
return Object.assign({}, group, {
|
||||
entries: entriesByGroupId.get(Number(group.id)) || []
|
||||
});
|
||||
});
|
||||
|
||||
return {
|
||||
scheduleGroups: groups,
|
||||
scheduleEntries: scheduleEntries
|
||||
timetableGroups: groups,
|
||||
timetableEntries: timetableEntries
|
||||
};
|
||||
}
|
||||
|
||||
async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||
async function fetchTimetableGroupsPage(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,
|
||||
@@ -69,10 +69,10 @@ async function fetchScheduleGroupsPage(pool, page, pageSize, searchTerm, sortKey
|
||||
pageSize: pageSize
|
||||
});
|
||||
|
||||
return Object.assign({ scheduleGroups: paged.rows }, paged);
|
||||
return Object.assign({ timetableGroups: paged.rows }, paged);
|
||||
}
|
||||
|
||||
async function fetchScheduleGroupById(pool, id) {
|
||||
async function fetchTimetableGroupById(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]
|
||||
@@ -81,25 +81,25 @@ async function fetchScheduleGroupById(pool, id) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
async function fetchScheduleEntriesByGroupId(pool, scheduleGroupId) {
|
||||
async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
|
||||
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]
|
||||
[timetableGroupId]
|
||||
);
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
function buildScheduleGroupPayload(req, existingScheduleGroup) {
|
||||
const fallback = existingScheduleGroup || {};
|
||||
function buildTimetableGroupPayload(req, existingTimetableGroup) {
|
||||
const fallback = existingTimetableGroup || {};
|
||||
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.');
|
||||
const error = new Error('Timetable group name is required.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
@@ -112,9 +112,9 @@ function buildScheduleGroupPayload(req, existingScheduleGroup) {
|
||||
|
||||
module.exports = {
|
||||
normalizeDisplayMode,
|
||||
fetchSchedulesData,
|
||||
fetchScheduleGroupsPage,
|
||||
fetchScheduleGroupById,
|
||||
fetchScheduleEntriesByGroupId,
|
||||
buildScheduleGroupPayload
|
||||
fetchTimetablesData,
|
||||
fetchTimetableGroupsPage,
|
||||
fetchTimetableGroupById,
|
||||
fetchTimetableEntriesByGroupId,
|
||||
buildTimetableGroupPayload
|
||||
};
|
||||
+1
-1
@@ -49,7 +49,7 @@ function normalizeEditorMarkup(value) {
|
||||
async function fetchSlideById(pool, id) {
|
||||
const [slides] = await pool.query(`
|
||||
SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
st.name AS template_name, st.canvas_size_id, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_slides s
|
||||
LEFT JOIN c_templates st ON st.id = s.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
|
||||
Vendored
+14
-2
@@ -1,7 +1,19 @@
|
||||
const { hashPassword } = require('../auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('../rbac');
|
||||
const { hashPassword } = require('#src/auth');
|
||||
const { PERMISSIONS, DEFAULT_ROLE } = require('#src/rbac');
|
||||
|
||||
async function bootstrapDatabase(pool) {
|
||||
const [canvasSizeCountRows] = await pool.query('SELECT COUNT(*) AS canvas_size_count FROM c_canvas_sizes');
|
||||
if (!canvasSizeCountRows.length || Number(canvasSizeCountRows[0].canvas_size_count) === 0) {
|
||||
await pool.query(`
|
||||
INSERT INTO c_canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
('Portrait Full HD', 1080, 1920),
|
||||
('Portrait HD', 720, 1280)
|
||||
`);
|
||||
}
|
||||
|
||||
for (const permission of PERMISSIONS) {
|
||||
await pool.query(
|
||||
`INSERT INTO a_permissions (permission_key, name, section_name, description, created_by, modified_by)
|
||||
|
||||
+23
-16
@@ -20,10 +20,12 @@ async function ensureSchema(pool, options) {
|
||||
name VARCHAR(255) NOT NULL,
|
||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
|
||||
canvas_id INT 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
|
||||
modified_by INT NULL,
|
||||
CONSTRAINT fk_playlists_canvas FOREIGN KEY (canvas_id) REFERENCES c_canvas_sizes(id) ON DELETE SET NULL
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
@@ -41,15 +43,6 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
INSERT IGNORE INTO c_canvas_sizes (name, width, height) VALUES
|
||||
('Full HD', 1920, 1080),
|
||||
('HD', 1280, 720),
|
||||
('4K UHD', 3840, 2160),
|
||||
('Portrait Full HD', 1080, 1920),
|
||||
('Portrait HD', 720, 1280)
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_template_regions (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
@@ -93,12 +86,7 @@ async function ensureSchema(pool, options) {
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
|
||||
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
||||
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||
schedule_start_datetime DATETIME NULL,
|
||||
schedule_end_datetime DATETIME NULL,
|
||||
schedule_start_time TIME NULL,
|
||||
schedule_end_time TIME NULL,
|
||||
schedule_days_json JSON NULL,
|
||||
disable_audio TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
created_by INT NULL,
|
||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
@@ -108,6 +96,25 @@ async function ensureSchema(pool, options) {
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
start_datetime DATETIME NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
start_time TIME NULL,
|
||||
end_time TIME NULL,
|
||||
schedule_days_json JSON 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_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
|
||||
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_screens (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
|
||||
+234
-1
@@ -108,6 +108,71 @@ const VERSIONED_MIGRATIONS = [
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
}
|
||||
|
||||
},
|
||||
{
|
||||
version: '2.4.0',
|
||||
label: 'v2.4.0 playlist schedule rules schema',
|
||||
run: async function (pool) {
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS c_playlist_slide_schedule_rules (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
playlist_slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
start_datetime DATETIME NULL,
|
||||
end_datetime DATETIME NULL,
|
||||
start_time TIME NULL,
|
||||
end_time TIME NULL,
|
||||
schedule_days_json JSON 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_playlist_slide_schedule_rules_slide FOREIGN KEY (playlist_slide_id) REFERENCES c_playlist_slides(id) ON DELETE CASCADE,
|
||||
INDEX idx_playlist_slide_schedule_rules_slide_position (playlist_slide_id, position)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
|
||||
const legacyColumnNames = [
|
||||
'schedule_mode',
|
||||
'schedule_start_datetime',
|
||||
'schedule_end_datetime',
|
||||
'schedule_start_time',
|
||||
'schedule_end_time',
|
||||
'schedule_days_json',
|
||||
'schedule_rules_json'
|
||||
];
|
||||
const existingLegacyColumns = [];
|
||||
for (const columnName of legacyColumnNames) {
|
||||
if (await columnExists(pool, 'c_playlist_slides', columnName)) {
|
||||
existingLegacyColumns.push(columnName);
|
||||
}
|
||||
}
|
||||
|
||||
const selectColumns = ['id'].concat(existingLegacyColumns).join(', ');
|
||||
const [legacyRows] = await pool.query('SELECT ' + selectColumns + ' FROM c_playlist_slides');
|
||||
|
||||
for (const row of legacyRows) {
|
||||
const rules = normalizeLegacyScheduleRules(row);
|
||||
if (rules.length) {
|
||||
for (let index = 0; index < rules.length; index += 1) {
|
||||
const rule = rules[index];
|
||||
await pool.query(
|
||||
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, NULL, NULL)',
|
||||
[row.id, index, rule.start_datetime || null, rule.end_datetime || null, rule.start_time || null, rule.end_time || null, rule.schedule_days_json ? JSON.stringify(rule.schedule_days_json) : null]
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_rules_json');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_mode');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_datetime');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_datetime');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_start_time');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_end_time');
|
||||
await dropColumnIfExists(pool, 'c_playlist_slides', 'schedule_days_json');
|
||||
await pool.query(`
|
||||
CREATE TABLE IF NOT EXISTS d_announcement_screens (
|
||||
announcement_id INT NOT NULL,
|
||||
@@ -123,7 +188,35 @@ const VERSIONED_MIGRATIONS = [
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci
|
||||
`);
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
version: '2.4.2',
|
||||
label: 'v2.4.2 playlist canvas id schema',
|
||||
run: async function (pool) {
|
||||
await ensureColumn(pool, 'c_playlists', 'canvas_id', 'INT NULL', 'skip_unavailable_rtmp');
|
||||
await ensureForeignKey(pool, 'c_playlists', 'fk_playlists_canvas', 'canvas_id', 'c_canvas_sizes', 'id', 'SET NULL');
|
||||
await ensureColumn(pool, 'c_playlist_slides', 'disable_audio', 'TINYINT(1) NOT NULL DEFAULT 1', 'use_video_duration');
|
||||
await pool.query(`
|
||||
UPDATE c_playlists p
|
||||
JOIN (
|
||||
SELECT ps.playlist_id,
|
||||
COUNT(DISTINCT CONCAT(cs.width, 'x', cs.height)) AS signature_count,
|
||||
MIN(CONCAT(cs.width, 'x', cs.height)) AS canvas_signature
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL
|
||||
GROUP BY ps.playlist_id
|
||||
) x ON x.playlist_id = p.id
|
||||
JOIN c_canvas_sizes cs ON CONCAT(cs.width, 'x', cs.height) = x.canvas_signature
|
||||
SET p.canvas_id = cs.id
|
||||
WHERE (p.canvas_id IS NULL)
|
||||
AND x.signature_count = 1
|
||||
`);
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
async function columnExists(pool, tableName, columnName) {
|
||||
@@ -148,6 +241,146 @@ async function ensureColumn(pool, tableName, columnName, columnDefinition, after
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD COLUMN ' + columnName + ' ' + columnDefinition + afterClause);
|
||||
}
|
||||
|
||||
async function ensureForeignKey(pool, tableName, constraintName, columnName, referencedTable, referencedColumn, onDeleteAction) {
|
||||
const [rows] = await pool.query(
|
||||
`SELECT COUNT(*) AS fk_count
|
||||
FROM information_schema.KEY_COLUMN_USAGE
|
||||
WHERE TABLE_SCHEMA = DATABASE()
|
||||
AND TABLE_NAME = ?
|
||||
AND CONSTRAINT_NAME = ?`,
|
||||
[tableName, constraintName]
|
||||
);
|
||||
|
||||
if (Number(rows && rows[0] && rows[0].fk_count) > 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
await pool.query('ALTER TABLE ' + tableName + ' ADD CONSTRAINT ' + constraintName + ' FOREIGN KEY (' + columnName + ') REFERENCES ' + referencedTable + '(' + referencedColumn + ') ON DELETE ' + onDeleteAction);
|
||||
}
|
||||
|
||||
async function dropColumnIfExists(pool, tableName, columnName) {
|
||||
if (await columnExists(pool, tableName, columnName)) {
|
||||
await pool.query('ALTER TABLE ' + tableName + ' DROP COLUMN ' + columnName);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLegacyScheduleRules(row) {
|
||||
const rules = [];
|
||||
if (!row) {
|
||||
return rules;
|
||||
}
|
||||
|
||||
if (row.schedule_rules_json) {
|
||||
try {
|
||||
const parsed = JSON.parse(row.schedule_rules_json);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach(function (rule) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (normalized) {
|
||||
rules.push(normalized);
|
||||
}
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall through to legacy columns
|
||||
}
|
||||
}
|
||||
|
||||
const mode = String(row.schedule_mode || 'always').trim().toLowerCase();
|
||||
if (mode === 'dates' && row.schedule_start_datetime && row.schedule_end_datetime) {
|
||||
rules.push({
|
||||
start_datetime: formatMigrationDateTime(row.schedule_start_datetime),
|
||||
end_datetime: formatMigrationDateTime(row.schedule_end_datetime)
|
||||
});
|
||||
return rules;
|
||||
}
|
||||
|
||||
if (mode === 'times' && row.schedule_start_time && row.schedule_end_time) {
|
||||
const days = normalizeMigrationDays(row.schedule_days_json);
|
||||
const rule = {
|
||||
start_time: formatMigrationTime(row.schedule_start_time),
|
||||
end_time: formatMigrationTime(row.schedule_end_time)
|
||||
};
|
||||
if (days.length) {
|
||||
rule.schedule_days_json = days;
|
||||
}
|
||||
rules.push(rule);
|
||||
}
|
||||
|
||||
return rules;
|
||||
}
|
||||
|
||||
function normalizeScheduleRule(rule) {
|
||||
if (!rule || typeof rule !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const normalized = {};
|
||||
if (rule.start_datetime) {
|
||||
normalized.start_datetime = String(rule.start_datetime);
|
||||
}
|
||||
if (rule.end_datetime) {
|
||||
normalized.end_datetime = String(rule.end_datetime);
|
||||
}
|
||||
if (rule.start_time) {
|
||||
normalized.start_time = String(rule.start_time).slice(0, 5);
|
||||
}
|
||||
if (rule.end_time) {
|
||||
normalized.end_time = String(rule.end_time).slice(0, 5);
|
||||
}
|
||||
if (Array.isArray(rule.schedule_days_json)) {
|
||||
normalized.schedule_days_json = rule.schedule_days_json;
|
||||
} else if (Array.isArray(rule.days)) {
|
||||
normalized.schedule_days_json = rule.days;
|
||||
}
|
||||
return Object.keys(normalized).length ? normalized : null;
|
||||
}
|
||||
|
||||
function formatMigrationDateTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return year + '-' + month + '-' + day + 'T' + hours + ':' + minutes;
|
||||
}
|
||||
|
||||
function formatMigrationTime(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
return String(value).slice(0, 5);
|
||||
}
|
||||
|
||||
function normalizeMigrationDays(value) {
|
||||
let parsed = [];
|
||||
if (Array.isArray(value)) {
|
||||
parsed = value;
|
||||
} else if (value) {
|
||||
try {
|
||||
const raw = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
parsed = Array.isArray(raw) ? raw : [];
|
||||
} catch (_error) {
|
||||
parsed = [];
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(parsed.map(function (day) {
|
||||
return Number(day);
|
||||
}).filter(function (day) {
|
||||
return Number.isInteger(day) && day >= 0 && day <= 6;
|
||||
}))).sort(function (left, right) {
|
||||
return left - right;
|
||||
});
|
||||
}
|
||||
|
||||
function compareVersions(leftVersion, rightVersion) {
|
||||
const leftParts = String(leftVersion || '0.0.0').split('.').map(function (value) {
|
||||
return Number(value) || 0;
|
||||
|
||||
+48
-24
@@ -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: [], scheduleGroups: [] };
|
||||
return { screen: null, playlist: null, slides: [], rssFeeds: [], apiSources: [], timetableGroups: [] };
|
||||
}
|
||||
|
||||
const screen = screenRows[0];
|
||||
@@ -71,8 +71,8 @@ function createPlayerPlaylistService(options) {
|
||||
slides: [],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
scheduleGroups: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [], [], [])
|
||||
timetableGroups: [],
|
||||
revision: getPlaylistRevision(screen, null, [], [], [], [], [], [])
|
||||
};
|
||||
await writeSnapshot(slug, payloadWithoutPlaylist);
|
||||
return payloadWithoutPlaylist;
|
||||
@@ -82,7 +82,7 @@ function createPlayerPlaylistService(options) {
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.template_id, sl.content_json, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.disable_audio,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
@@ -91,6 +91,21 @@ function createPlayerPlaylistService(options) {
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY ps.position ASC, ps.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
const [scheduleRuleRows] = await pool.query(`
|
||||
SELECT r.id, r.playlist_slide_id, r.position, r.start_datetime, r.end_datetime, r.start_time, r.end_time, r.schedule_days_json, r.created_at, r.modified_at, r.created_by, r.modified_by
|
||||
FROM c_playlist_slide_schedule_rules r
|
||||
JOIN c_playlist_slides ps ON ps.id = r.playlist_slide_id
|
||||
WHERE ps.playlist_id = ?
|
||||
ORDER BY r.playlist_slide_id ASC, r.position ASC, r.id ASC
|
||||
`, [screen.playlist_id]);
|
||||
const rulesBySlideId = {};
|
||||
scheduleRuleRows.forEach(function (rule) {
|
||||
const slideId = Number(rule.playlist_slide_id);
|
||||
if (!rulesBySlideId[slideId]) {
|
||||
rulesBySlideId[slideId] = [];
|
||||
}
|
||||
rulesBySlideId[slideId].push(rule);
|
||||
});
|
||||
|
||||
const templateIds = slideRows
|
||||
.filter(function (slide) { return slide.template_id; })
|
||||
@@ -138,10 +153,17 @@ function createPlayerPlaylistService(options) {
|
||||
const slides = slideRows.map(function (slide) {
|
||||
const storedDuration = Number(slide.duration_seconds || 0);
|
||||
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
|
||||
const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio);
|
||||
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||
const content = common.parseJsonSafe(slide.content_json) || {};
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (region && typeof region === 'object') {
|
||||
const regionType = String(region.type || '').trim().toLowerCase();
|
||||
if (regionType === 'video' || regionType === 'rtmp') {
|
||||
region.disable_audio = disableAudio;
|
||||
}
|
||||
}
|
||||
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
|
||||
region.cache_bust = videoCacheBust;
|
||||
}
|
||||
@@ -152,12 +174,8 @@ function createPlayerPlaylistService(options) {
|
||||
modified_at: slide.modified_at,
|
||||
duration_seconds: videoDuration || storedDuration,
|
||||
use_video_duration: Boolean(slide.use_video_duration),
|
||||
schedule_mode: slide.schedule_mode,
|
||||
schedule_start_datetime: slide.schedule_start_datetime,
|
||||
schedule_end_datetime: slide.schedule_end_datetime,
|
||||
schedule_start_time: slide.schedule_start_time,
|
||||
schedule_end_time: slide.schedule_end_time,
|
||||
schedule_days_json: slide.schedule_days_json,
|
||||
disable_audio: disableAudio,
|
||||
scheduleRules: rulesBySlideId[Number(slide.id)] || [],
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: content
|
||||
@@ -187,14 +205,14 @@ function createPlayerPlaylistService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
let scheduleGroups = [];
|
||||
if (typeof common.fetchSchedulesData === 'function') {
|
||||
const scheduleData = await common.fetchSchedulesData(pool);
|
||||
scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : [];
|
||||
let timetableGroups = [];
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
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 };
|
||||
const revision = getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups);
|
||||
const payload = { screen: screen, playlist: playlist, slides: slides, rssFeeds: rssFeeds, apiSources: apiSources, timetableGroups: timetableGroups, revision: revision };
|
||||
await writeSnapshot(slug, payload);
|
||||
return payload;
|
||||
} catch (error) {
|
||||
@@ -211,7 +229,7 @@ function createPlayerPlaylistService(options) {
|
||||
hash.update('\0');
|
||||
}
|
||||
|
||||
function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRows, rssFeeds, apiSources, scheduleGroups) {
|
||||
function getPlaylistRevision(screen, playlist, slideRows, scheduleRuleRows, templateRows, regionRows, rssFeeds, apiSources, timetableGroups) {
|
||||
const hash = crypto.createHash('sha1');
|
||||
|
||||
updatePlaylistRevisionHash(hash, screen && screen.id);
|
||||
@@ -232,12 +250,18 @@ function createPlayerPlaylistService(options) {
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_time);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_days_json);
|
||||
updatePlaylistRevisionHash(hash, slide.disable_audio);
|
||||
});
|
||||
|
||||
(Array.isArray(scheduleRuleRows) ? scheduleRuleRows : []).forEach(function (rule) {
|
||||
updatePlaylistRevisionHash(hash, rule.id);
|
||||
updatePlaylistRevisionHash(hash, rule.playlist_slide_id);
|
||||
updatePlaylistRevisionHash(hash, rule.position);
|
||||
updatePlaylistRevisionHash(hash, rule.start_datetime);
|
||||
updatePlaylistRevisionHash(hash, rule.end_datetime);
|
||||
updatePlaylistRevisionHash(hash, rule.start_time);
|
||||
updatePlaylistRevisionHash(hash, rule.end_time);
|
||||
updatePlaylistRevisionHash(hash, rule.schedule_days_json);
|
||||
});
|
||||
|
||||
(Array.isArray(templateRows) ? templateRows : []).forEach(function (template) {
|
||||
@@ -268,7 +292,7 @@ function createPlayerPlaylistService(options) {
|
||||
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(rssFeeds || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(apiSources || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(scheduleGroups || []));
|
||||
updatePlaylistRevisionHash(hash, JSON.stringify(timetableGroups || []));
|
||||
|
||||
return hash.digest('hex');
|
||||
}
|
||||
|
||||
@@ -165,7 +165,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.muted = !(video.dataset && video.dataset.disableAudio === '0');
|
||||
video.playsInline = true;
|
||||
|
||||
function startPlayback() {
|
||||
|
||||
@@ -340,31 +340,82 @@ function parseTimeToMinutes(value) {
|
||||
return Number(match[1]) * 60 + Number(match[2]);
|
||||
}
|
||||
|
||||
// Determine whether a slide should be shown at the current time.
|
||||
function isSlideActive(slide, now) {
|
||||
const mode = String(slide.schedule_mode || 'always');
|
||||
if (mode === 'always') {
|
||||
return true;
|
||||
function normalizeScheduleRule(rule) {
|
||||
const input = rule && typeof rule === 'object' ? rule : {};
|
||||
const startDatetime = String(input.start_datetime || input.startDateTime || '').trim();
|
||||
const endDatetime = String(input.end_datetime || input.endDateTime || '').trim();
|
||||
const startTime = String(input.start_time || input.startTime || '').trim();
|
||||
const endTime = String(input.end_time || input.endTime || '').trim();
|
||||
const days = parseScheduleDays(input.days);
|
||||
|
||||
if (startDatetime || endDatetime) {
|
||||
if (!startDatetime || !endDatetime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
if (mode === 'dates') {
|
||||
const start = slide.schedule_start_datetime ? new Date(slide.schedule_start_datetime) : null;
|
||||
const end = slide.schedule_end_datetime ? new Date(slide.schedule_end_datetime) : null;
|
||||
if (!start || !end || Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return false;
|
||||
if (startTime || endTime) {
|
||||
if (!startTime || !endTime) {
|
||||
return null;
|
||||
}
|
||||
return now >= start && now <= end;
|
||||
}
|
||||
if (mode === 'times') {
|
||||
const days = parseScheduleDays(slide.schedule_days_json);
|
||||
if (!days.length) {
|
||||
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
start_datetime: startDatetime || null,
|
||||
end_datetime: endDatetime || null,
|
||||
start_time: startTime || null,
|
||||
end_time: endTime || null,
|
||||
days: days
|
||||
};
|
||||
}
|
||||
|
||||
function parseScheduleRules(value) {
|
||||
let rawRules = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
rawRules = value;
|
||||
} else {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(raw);
|
||||
rawRules = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
return rawRules.map(normalizeScheduleRule).filter(Boolean);
|
||||
}
|
||||
|
||||
function matchesScheduleRule(rule, now) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.start_datetime && normalized.end_datetime) {
|
||||
const start = new Date(normalized.start_datetime);
|
||||
const end = new Date(normalized.end_datetime);
|
||||
if (Number.isNaN(start.getTime()) || Number.isNaN(end.getTime())) {
|
||||
return false;
|
||||
}
|
||||
const day = now.getDay();
|
||||
if (days.indexOf(day) === -1) {
|
||||
if (now < start || now > end) {
|
||||
return false;
|
||||
}
|
||||
const startMinutes = parseTimeToMinutes(slide.schedule_start_time);
|
||||
const endMinutes = parseTimeToMinutes(slide.schedule_end_time);
|
||||
}
|
||||
|
||||
if (normalized.days.length && normalized.days.indexOf(now.getDay()) === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (normalized.start_time && normalized.end_time) {
|
||||
const startMinutes = parseTimeToMinutes(normalized.start_time);
|
||||
const endMinutes = parseTimeToMinutes(normalized.end_time);
|
||||
if (startMinutes === null || endMinutes === null) {
|
||||
return false;
|
||||
}
|
||||
@@ -374,8 +425,20 @@ function isSlideActive(slide, now) {
|
||||
}
|
||||
return nowMinutes >= startMinutes || nowMinutes <= endMinutes;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Determine whether a slide should be shown at the current time.
|
||||
function isSlideActive(slide, now) {
|
||||
const rules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
|
||||
if (!rules.length) {
|
||||
return true;
|
||||
}
|
||||
return rules.some(function (rule) {
|
||||
return matchesScheduleRule(rule, now);
|
||||
});
|
||||
}
|
||||
// Build the cache key for a template layout.
|
||||
function getTemplateLayoutCacheKey(template) {
|
||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
|
||||
@@ -8,7 +8,7 @@ function renderRtmpRegion(region, regionContent) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture' + (disableAudio ? ' muted' : '') + '></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||
}
|
||||
|
||||
function getRtmpSessionUrl(sourceUrl, disableAudio) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// Schedule region rendering for live playback.
|
||||
// Timetable region rendering for live playback.
|
||||
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
@@ -97,7 +97,7 @@ function sanitizeRichText(html) {
|
||||
});
|
||||
}
|
||||
|
||||
function substituteScheduleVariables(html, entry) {
|
||||
function substituteTimetableVariables(html, entry) {
|
||||
var source = String(html || '');
|
||||
return source.replace(/\{\{\s*([^{}]+?)\s*\}\}/g, function (_match, expression) {
|
||||
if (!entry || typeof entry !== 'object') {
|
||||
@@ -117,16 +117,16 @@ function renderTemplate(template, context) {
|
||||
if (!source) {
|
||||
return '';
|
||||
}
|
||||
return substituteScheduleVariables(source, context);
|
||||
return substituteTimetableVariables(source, context);
|
||||
}
|
||||
|
||||
function getScheduleGroups() {
|
||||
return Array.isArray(initialData && initialData.scheduleGroups) ? initialData.scheduleGroups : [];
|
||||
function getTimetableGroups() {
|
||||
return Array.isArray(initialData && initialData.timetableGroups) ? initialData.timetableGroups : [];
|
||||
}
|
||||
|
||||
function getGroupById(groupId, groups) {
|
||||
var normalizedId = Number(groupId || 0);
|
||||
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
|
||||
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
|
||||
return Number(group.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
@@ -174,6 +174,12 @@ function getVisibleEntries(groupId, displayMode, maxItems, groups) {
|
||||
return isUpcoming(entry, now);
|
||||
});
|
||||
|
||||
if (!entries.length && mode === 'upcoming') {
|
||||
entries = getEntries(groupId, groups).filter(function (entry) {
|
||||
return isLive(entry, now);
|
||||
});
|
||||
}
|
||||
|
||||
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
|
||||
}
|
||||
|
||||
@@ -216,22 +222,23 @@ function getTextStyle(region, regionContent) {
|
||||
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 groups = getTimetableGroups();
|
||||
var groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_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)));
|
||||
}
|
||||
var width = Math.max(1, Math.round(Number(region && region.pixelWidth ? region.pixelWidth : 0) || 1));
|
||||
var height = Math.max(1, Math.round(Number(region && region.pixelHeight ? region.pixelHeight : 0) || 1));
|
||||
var canvasScale = Number(region && region.canvasScale ? region.canvasScale : 1) || 1;
|
||||
var contentStyle = 'width:' + width + 'px;height:' + height + 'px;transform:scale(' + canvasScale + ');transform-origin:top left;' + (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) + ';' : '');
|
||||
|
||||
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 timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;"></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 || {}, {
|
||||
return '<div class="template-region timetable" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + ' overflow:hidden;">' + entries.map(function (entry, index) {
|
||||
return '<div class="timetable-region-entry" data-timetable-entry-index="' + index + '">' + sanitizeRichText(substituteTimetableVariables(value, Object.assign({}, entry || {}, {
|
||||
start: entry && entry.start_datetime !== undefined ? entry.start_datetime : '',
|
||||
end: entry && entry.end_datetime !== undefined ? entry.end_datetime : '',
|
||||
group: group || {},
|
||||
@@ -241,6 +248,6 @@ function renderRegion(region, regionContent) {
|
||||
}).join('') + '</div></div>';
|
||||
}
|
||||
|
||||
registry.register('schedule', {
|
||||
registry.register('timetable', {
|
||||
renderRegion: renderRegion
|
||||
});
|
||||
@@ -111,6 +111,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
var regionKey = getVideoRegionCacheKey(region);
|
||||
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
|
||||
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
|
||||
var disableAudio = regionContent && regionContent.disable_audio === undefined ? true : Boolean(regionContent && regionContent.disable_audio);
|
||||
|
||||
if (!requestedSrc) {
|
||||
return '';
|
||||
@@ -121,7 +122,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
setVideoSourceAvailability(requestedSrc, true);
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||
@@ -131,7 +132,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||
@@ -140,7 +141,7 @@ function renderVideoRegion(region, regionContent) {
|
||||
if (cachedSrc !== requestedSrc) {
|
||||
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" data-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay' + (disableAudio ? ' muted' : '') + ' loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
return '';
|
||||
|
||||
@@ -4,7 +4,7 @@ const Handlebars = require('handlebars');
|
||||
const path = require('path');
|
||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getAnnouncementIconsDataScript, getPlayerAnnouncementTemplatesDataScript, getPlayerAnnouncementTemplatesScript, getPlayerPageAnnouncementsScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
|
||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('#src/request-auth');
|
||||
const { getFontStylesheetHref } = require('#src/web/lib/media/font-library');
|
||||
|
||||
const PLAYER_MEDIA_DIR = path.join(__dirname, '..', '..', 'media');
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
const fs = require('fs');
|
||||
const express = require('express');
|
||||
const path = require('path');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||
|
||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||
@@ -259,9 +259,9 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof common.fetchSchedulesData === 'function') {
|
||||
const scheduleData = await common.fetchSchedulesData(pool);
|
||||
data.scheduleGroups = Array.isArray(scheduleData && scheduleData.scheduleGroups) ? scheduleData.scheduleGroups : [];
|
||||
if (typeof common.fetchTimetablesData === 'function') {
|
||||
const timetableData = await common.fetchTimetablesData(pool);
|
||||
data.timetableGroups = Array.isArray(timetableData && timetableData.timetableGroups) ? timetableData.timetableGroups : [];
|
||||
}
|
||||
|
||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
|
||||
const crypto = require('crypto');
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { isClientNameAvailable } = require('../data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||
const { isClientNameAvailable } = require('#src/data/client-name-check');
|
||||
const { verifyPageAuthToken, verifyRequestAuth } = require('#src/request-auth');
|
||||
|
||||
function normalizePlayerPublicBaseUrl(pageUrl) {
|
||||
const value = String(pageUrl || '').trim();
|
||||
|
||||
@@ -15,7 +15,7 @@ function buildThumbnailPreviewData(slide) {
|
||||
slides: [slide],
|
||||
rssFeeds: [],
|
||||
apiSources: [],
|
||||
scheduleGroups: [],
|
||||
timetableGroups: [],
|
||||
revision: String(slide.modified_at || slide.id || Date.now())
|
||||
};
|
||||
}
|
||||
|
||||
+60
-57
@@ -33,18 +33,6 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 35,
|
||||
name: 'Announcements',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View announcements.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update announcements.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'playlists',
|
||||
order: 40,
|
||||
@@ -93,9 +81,21 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 80,
|
||||
name: 'Announcements',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View announcements.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update announcements.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rss-feeds',
|
||||
order: 75,
|
||||
order: 90,
|
||||
name: 'RSS feeds',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
@@ -105,21 +105,9 @@ 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,
|
||||
order: 100,
|
||||
name: 'API sources',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
@@ -130,39 +118,20 @@ const PERMISSION_SECTIONS = [
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 95,
|
||||
name: 'Fonts',
|
||||
sectionName: 'Settings',
|
||||
key: 'timetables',
|
||||
order: 110,
|
||||
name: 'Timetables',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 100,
|
||||
name: 'Background tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 101,
|
||||
name: 'Scheduled tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
{ key: 'read', name: 'Read', description: 'View configured timetable groups.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new timetable groups.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
order: 80,
|
||||
order: 120,
|
||||
name: 'Users',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
@@ -174,7 +143,7 @@ const PERMISSION_SECTIONS = [
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 90,
|
||||
order: 130,
|
||||
name: 'Roles and permissions',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
@@ -183,6 +152,37 @@ const PERMISSION_SECTIONS = [
|
||||
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 140,
|
||||
name: 'Fonts',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 150,
|
||||
name: 'Task Queue',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 160,
|
||||
name: 'Scheduled tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
@@ -211,6 +211,8 @@ function normalizePermissionKey(permissionKey) {
|
||||
return String(permissionKey || '').trim();
|
||||
}
|
||||
|
||||
const PERMISSION_DENIED_MESSAGE = 'You do not have permissions to do this.';
|
||||
|
||||
function normalizePermissionKeys(permissionKeys) {
|
||||
const normalized = [];
|
||||
|
||||
@@ -295,7 +297,7 @@ function requirePermission(permissionKey, options) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -309,5 +311,6 @@ module.exports = {
|
||||
hasPermission,
|
||||
hasAnyPermission,
|
||||
requirePermission,
|
||||
normalizePermissionKeys
|
||||
normalizePermissionKeys,
|
||||
PERMISSION_DENIED_MESSAGE
|
||||
};
|
||||
+14
-3
@@ -11,13 +11,14 @@ const pages = require('#src/web/pages');
|
||||
const registerMiddleware = require('#src/web/middleware');
|
||||
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
|
||||
const { initializeBackgroundTasks } = require('#src/web/lib/background-tasks');
|
||||
const { createDataSourceTaskService } = require('#src/web/lib/background-tasks/tasks-scheduled/data-source-refresh');
|
||||
const { captureSlideThumbnail } = require('#src/web/lib/media');
|
||||
const { createWebBootstrap } = require('#src/web/bootstrap');
|
||||
const { registerRoutes } = require('#src/web/routes/register');
|
||||
const { createNotifyPlayerScreens } = require('#src/web/lib/notify-player-screens');
|
||||
const { initializeWebServer } = require('#src/web/lib/web-startup');
|
||||
const { createWebConfig } = require('#src/web/lib/config');
|
||||
const { requirePermission: createRequirePermission } = require('#src/rbac');
|
||||
const { requirePermission: createRequirePermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const { rbacData } = require('#src/web/lib/auth');
|
||||
const { createPlayerActionService } = require('#src/web/lib/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
@@ -32,6 +33,7 @@ const {
|
||||
normalizeScheduleMode,
|
||||
getAuditUserId,
|
||||
getCanvasSignature,
|
||||
fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature,
|
||||
fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId,
|
||||
@@ -52,6 +54,11 @@ async function start() {
|
||||
pool: pool,
|
||||
maxConcurrent: 1
|
||||
});
|
||||
const dataSourceTasks = createDataSourceTaskService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
const playerActionService = createPlayerActionService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
@@ -130,6 +137,7 @@ async function start() {
|
||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
getCanvasSignature: getCanvasSignature,
|
||||
normalizeScheduleMode: normalizeScheduleMode,
|
||||
@@ -138,10 +146,13 @@ async function start() {
|
||||
redirectAfterSave: redirectAfterSave,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
dataSourceTasks: dataSourceTasks,
|
||||
playerActionService: playerActionService,
|
||||
isClientNameAvailable: isClientNameAvailable,
|
||||
withClientNameReservation: withClientNameReservation,
|
||||
requirePermission: createRequirePermission,
|
||||
requirePermission: function (permissionKey, options) {
|
||||
return createRequirePermission(permissionKey, Object.assign({ setAuthMessageCookie: setAuthMessageCookie }, options));
|
||||
},
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
@@ -161,7 +172,7 @@ async function start() {
|
||||
if (wantsHtml && pages.renderErrorPage) {
|
||||
const isPermissionError = statusCode === 403;
|
||||
const message = isPermissionError
|
||||
? String(error && error.message ? error.message : 'You do not have permission to access this area.')
|
||||
? String(error && error.message ? error.message : PERMISSION_DENIED_MESSAGE)
|
||||
: String(error && error.message ? error.message : 'An unexpected error occurred.');
|
||||
const title = isPermissionError
|
||||
? 'Access denied'
|
||||
|
||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
||||
const { WebSocketServer, WebSocket } = require('ws');
|
||||
const { createDashboardStateService } = require('./lib/dashboard-state');
|
||||
const { createUploadSyncService } = require('./lib/media');
|
||||
const { createRequestAuthHeaders } = require('../request-auth');
|
||||
const { createRequestAuthHeaders } = require('#src/request-auth');
|
||||
|
||||
function createWebBootstrap(options) {
|
||||
const pool = options && options.pool;
|
||||
|
||||
+59
-16
@@ -82,18 +82,42 @@ function registerActionHelpers(Handlebars) {
|
||||
Handlebars.registerHelper('saveActionButtons', function (options) {
|
||||
const hash = options && options.hash ? options.hash : {};
|
||||
const formId = String(hash.formId || '').trim();
|
||||
const saveLabel = String(hash.saveLabel || 'Save');
|
||||
const saveButtonClass = String(hash.saveButtonClass || 'btn btn-success');
|
||||
const saveActionName = String(hash.saveActionName || 'save_action');
|
||||
const saveActionValue = String(hash.saveActionValue || 'save');
|
||||
const closeLabel = String(hash.saveAndCloseLabel || 'Save and Close');
|
||||
const closeValue = String(hash.saveAndCloseValue || 'close');
|
||||
const newLabel = String(hash.saveAndNewLabel || 'Save and New');
|
||||
const newValue = String(hash.saveAndNewValue || 'new');
|
||||
|
||||
const save = {
|
||||
label: String(hash.saveLabel || 'Save'),
|
||||
className: String(hash.saveButtonClass || 'btn btn-success'),
|
||||
actionName: String(hash.saveActionName || 'save_action'),
|
||||
actionValue: String(hash.saveActionValue || 'save'),
|
||||
closeLabel: String(hash.saveAndCloseLabel || 'Save and Close'),
|
||||
closeValue: String(hash.saveAndCloseValue || 'close'),
|
||||
newLabel: String(hash.saveAndNewLabel || 'Save and New'),
|
||||
newValue: String(hash.saveAndNewValue || 'new')
|
||||
};
|
||||
|
||||
const showSaveAndClose = hash.showSaveAndClose === undefined ? true : String(hash.showSaveAndClose).toLowerCase() !== 'false';
|
||||
const showSaveAndNew = hash.showSaveAndNew === undefined ? true : String(hash.showSaveAndNew).toLowerCase() !== 'false';
|
||||
const ariaLabel = String(hash.ariaLabel || 'Save actions');
|
||||
const showDelete = hash.showDelete === undefined ? true : String(hash.showDelete).toLowerCase() !== 'false';
|
||||
const hasSecondaryActions = showSaveAndClose || showSaveAndNew;
|
||||
const ariaLabel = String(hash.ariaLabel || 'Save actions');
|
||||
const saveUrl = String(hash.saveUrl || '').trim();
|
||||
|
||||
const cancel = {
|
||||
label: String(hash.cancelLabel || 'Cancel'),
|
||||
className: String(hash.cancelClass || 'btn btn-warning'),
|
||||
url: String(hash.cancelUrl || '').trim(),
|
||||
confirmMessage: String(hash.cancelConfirmMessage || hash.cancelConfirm || 'You have unsaved changes. Leave this page?')
|
||||
};
|
||||
|
||||
const deleteAction = {
|
||||
label: String(hash.deleteLabel || 'Delete'),
|
||||
className: String(hash.deleteClass || 'btn btn-danger'),
|
||||
url: String(hash.deleteUrl || '').trim(),
|
||||
confirmMessage: String(hash.deleteConfirmMessage || '').trim(),
|
||||
disabled: hash.deleteDisabled === undefined ? false : String(hash.deleteDisabled).toLowerCase() !== 'false',
|
||||
title: String(hash.deleteTitle || '')
|
||||
};
|
||||
|
||||
const resolvedDeleteUrl = deleteAction.url || (!deleteAction.disabled && saveUrl ? saveUrl.replace(/\/+$/, '') + '/delete' : '');
|
||||
|
||||
if (!formId) {
|
||||
return '';
|
||||
@@ -102,16 +126,30 @@ function registerActionHelpers(Handlebars) {
|
||||
const escape = Handlebars.escapeExpression;
|
||||
const formAttr = ` form="${escape(formId)}"`;
|
||||
|
||||
return new Handlebars.SafeString([
|
||||
const parts = [
|
||||
'<div class="btn-group save-action-group" role="group">',
|
||||
`<button type="submit" class="${escape(saveButtonClass)}"${formAttr} name="${escape(saveActionName)}" value="${escape(saveActionValue)}">${escape(saveLabel)}</button>`,
|
||||
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
|
||||
`<button type="submit" class="${escape(save.className)}"${formAttr} name="${escape(save.actionName)}" value="${escape(save.actionValue)}">${escape(save.label)}</button>`,
|
||||
hasSecondaryActions ? `<button type="button" class="btn btn-success dropdown-toggle dropdown-toggle-split rounded-0" data-bs-toggle="dropdown" aria-expanded="false"><span class="visually-hidden">${escape(ariaLabel)}</span></button>` : '',
|
||||
hasSecondaryActions ? '<div class="dropdown-menu dropdown-menu-end">' : '',
|
||||
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(closeValue)}">${escape(closeLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(saveActionName)}" value="${escape(newValue)}">${escape(newLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndClose ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.closeValue)}">${escape(save.closeLabel)}</button>` : '',
|
||||
hasSecondaryActions && showSaveAndNew ? `<button type="submit" class="dropdown-item"${formAttr} name="${escape(save.actionName)}" value="${escape(save.newValue)}">${escape(save.newLabel)}</button>` : '',
|
||||
hasSecondaryActions ? '</div>' : '',
|
||||
'</div>',
|
||||
].join(''));
|
||||
];
|
||||
|
||||
if (cancel.url) {
|
||||
parts.push(`<a class="${escape(cancel.className)}" href="${escape(cancel.url)}" data-confirm-unsaved="${escape(cancel.confirmMessage)}">${escape(cancel.label)}</a>`);
|
||||
}
|
||||
|
||||
if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) {
|
||||
const deleteActionAttr = resolvedDeleteUrl ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
|
||||
const confirmAttr = deleteAction.confirmMessage ? ` data-confirm-message="${escape(deleteAction.confirmMessage)}"` : '';
|
||||
const titleAttr = deleteAction.disabled && deleteAction.title ? ` title="${escape(deleteAction.title)}"` : '';
|
||||
parts.push(`<button type="button" class="${escape(deleteAction.className)}"${deleteActionAttr}${confirmAttr}${deleteAction.disabled ? ' disabled' : ''}${titleAttr}>${escape(deleteAction.label)}</button>`);
|
||||
}
|
||||
|
||||
parts.push('</div>');
|
||||
|
||||
return new Handlebars.SafeString(parts.join(''));
|
||||
});
|
||||
}
|
||||
|
||||
@@ -119,6 +157,11 @@ function registerActionHelpers(Handlebars) {
|
||||
function registerPartials(Handlebars, viewsRoot) {
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(viewsRoot, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(viewsRoot, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('playlists/form', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/slide-row', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'slide-row.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('signage/playlists/schedule-rule-card', fs.readFileSync(path.join(viewsRoot, 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/api-sources/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'api-sources', 'form.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('data-sources/rss-feeds/form', fs.readFileSync(path.join(viewsRoot, 'data-sources', 'rss-feeds', 'form.hbs'), 'utf8'));
|
||||
}
|
||||
|
||||
// One entry point keeps Handlebars bootstrap centralized.
|
||||
|
||||
+33
-20
@@ -90,25 +90,31 @@ function getCanvasSignature(width, height) {
|
||||
return normalizedWidth + 'x' + normalizedHeight;
|
||||
}
|
||||
|
||||
async function fetchPlaylistCanvasSignature(pool, playlistId) {
|
||||
async function fetchPlaylistCanvasId(pool, playlistId) {
|
||||
const [playlistRows] = await pool.query('SELECT canvas_id FROM c_playlists WHERE id = ?', [playlistId]);
|
||||
const storedCanvasId = Number(playlistRows && playlistRows[0] && playlistRows[0].canvas_id);
|
||||
if (Number.isInteger(storedCanvasId) && storedCanvasId > 0) {
|
||||
return storedCanvasId;
|
||||
}
|
||||
|
||||
const [rows] = await pool.query(
|
||||
`SELECT DISTINCT cs.width AS canvas_width, cs.height AS canvas_height
|
||||
`SELECT DISTINCT st.canvas_size_id AS canvas_id
|
||||
FROM c_playlist_slides ps
|
||||
JOIN c_slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE ps.playlist_id = ?
|
||||
AND cs.width IS NOT NULL
|
||||
AND cs.height IS NOT NULL`,
|
||||
AND st.canvas_size_id IS NOT NULL`,
|
||||
[playlistId]
|
||||
);
|
||||
const signatures = Array.from(new Set(rows.map(function (row) {
|
||||
return getCanvasSignature(row.canvas_width, row.canvas_height);
|
||||
}).filter(Boolean)));
|
||||
if (!signatures.length) {
|
||||
const canvasIds = Array.from(new Set(rows.map(function (row) {
|
||||
return Number(row.canvas_id);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value > 0;
|
||||
})));
|
||||
if (!canvasIds.length) {
|
||||
return null;
|
||||
}
|
||||
return signatures.length === 1 ? signatures[0] : 'mismatch';
|
||||
return canvasIds.length === 1 ? canvasIds[0] : 'mismatch';
|
||||
}
|
||||
|
||||
async function fetchScreensByPlaylistId(connection, playlistId) {
|
||||
@@ -160,19 +166,25 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
|
||||
|
||||
function redirectAfterSave(req, res, defaultUrl, options) {
|
||||
const safeOptions = options || {};
|
||||
const action = String((req && req.body && req.body.action) || req.query.action || '').toLowerCase();
|
||||
const action = String((req && req.body && (req.body.action || req.body.save_action)) || req.query.action || '').toLowerCase();
|
||||
const message = safeOptions.message || '';
|
||||
|
||||
function withMessage(url) {
|
||||
if (!message) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const joiner = String(url || '').indexOf('?') === -1 ? '?' : '&';
|
||||
return url + joiner + 'message=' + encodeURIComponent(message);
|
||||
}
|
||||
|
||||
if (action === 'close') {
|
||||
return res.redirect(safeOptions.closeUrl || defaultUrl);
|
||||
return res.redirect(withMessage(safeOptions.closeUrl || defaultUrl));
|
||||
}
|
||||
if (action === 'new') {
|
||||
return res.redirect(safeOptions.newUrl || defaultUrl);
|
||||
return res.redirect(withMessage(safeOptions.newUrl || defaultUrl));
|
||||
}
|
||||
const message = safeOptions.message || '';
|
||||
if (message) {
|
||||
const joiner = defaultUrl.indexOf('?') === -1 ? '?' : '&';
|
||||
return res.redirect(defaultUrl + joiner + 'message=' + encodeURIComponent(message));
|
||||
}
|
||||
return res.redirect(defaultUrl);
|
||||
return res.redirect(withMessage(defaultUrl));
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
@@ -185,7 +197,8 @@ module.exports = {
|
||||
normalizeScheduleMode: normalizeScheduleMode,
|
||||
getAuditUserId: getAuditUserId,
|
||||
getCanvasSignature: getCanvasSignature,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasSignature,
|
||||
fetchPlaylistCanvasId: fetchPlaylistCanvasId,
|
||||
fetchPlaylistCanvasSignature: fetchPlaylistCanvasId,
|
||||
fetchScreensByPlaylistId: fetchScreensByPlaylistId,
|
||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
// Shared schedule rule helpers for playlist playback and editor rendering.
|
||||
|
||||
const DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
|
||||
function normalizeDateTimeValue(value) {
|
||||
const raw = String(value || '').trim();
|
||||
return raw ? raw : null;
|
||||
}
|
||||
|
||||
function normalizeTimeValue(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
const match = raw.match(/^(\d{2}):(\d{2})(?::\d{2})?$/);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
return match[1] + ':' + match[2];
|
||||
}
|
||||
|
||||
function normalizeDayValues(value) {
|
||||
let days = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
days = value;
|
||||
} else if (value !== undefined && value !== null && String(value).trim()) {
|
||||
try {
|
||||
const parsed = typeof value === 'string' ? JSON.parse(value) : value;
|
||||
if (Array.isArray(parsed)) {
|
||||
days = parsed;
|
||||
}
|
||||
} catch (_error) {
|
||||
days = String(value)
|
||||
.split(',')
|
||||
.map(function (item) {
|
||||
return item.trim();
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Set(days.map(function (day) {
|
||||
return Number(day);
|
||||
}).filter(function (day) {
|
||||
return Number.isInteger(day) && day >= 0 && day <= 6;
|
||||
}))).sort(function (left, right) {
|
||||
return left - right;
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeScheduleRule(rule) {
|
||||
const input = rule && typeof rule === 'object' ? rule : {};
|
||||
const startDatetime = normalizeDateTimeValue(
|
||||
input.start_datetime || input.startDateTime || input.schedule_start_datetime || input.scheduleStartDatetime
|
||||
);
|
||||
const endDatetime = normalizeDateTimeValue(
|
||||
input.end_datetime || input.endDateTime || input.schedule_end_datetime || input.scheduleEndDatetime
|
||||
);
|
||||
const startTime = normalizeTimeValue(
|
||||
input.start_time || input.startTime || input.schedule_start_time || input.scheduleStartTime
|
||||
);
|
||||
const endTime = normalizeTimeValue(
|
||||
input.end_time || input.endTime || input.schedule_end_time || input.scheduleEndTime
|
||||
);
|
||||
const days = normalizeDayValues(
|
||||
input.days || input.day_list || input.schedule_days || input.schedule_days_json || input.scheduleDays
|
||||
);
|
||||
|
||||
if (startDatetime || endDatetime) {
|
||||
if (!startDatetime || !endDatetime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (startTime || endTime) {
|
||||
if (!startTime || !endTime) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (!startDatetime && !endDatetime && !startTime && !endTime && !days.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = {};
|
||||
if (startDatetime) {
|
||||
normalized.start_datetime = startDatetime;
|
||||
}
|
||||
if (endDatetime) {
|
||||
normalized.end_datetime = endDatetime;
|
||||
}
|
||||
if (startTime) {
|
||||
normalized.start_time = startTime;
|
||||
}
|
||||
if (endTime) {
|
||||
normalized.end_time = endTime;
|
||||
}
|
||||
if (days.length) {
|
||||
normalized.days = days;
|
||||
}
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function parseScheduleRules(value) {
|
||||
let rawRules = [];
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
rawRules = value;
|
||||
} else if (value && typeof value === 'object' && Array.isArray(value.rules)) {
|
||||
rawRules = value.rules;
|
||||
} else {
|
||||
const rawValue = String(value || '').trim();
|
||||
if (!rawValue) {
|
||||
return [];
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue);
|
||||
rawRules = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
return rawRules
|
||||
.map(function (rule) {
|
||||
return normalizeScheduleRule(rule);
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function serializeScheduleRules(value) {
|
||||
const normalized = parseScheduleRules(value);
|
||||
if (normalized === null) {
|
||||
return null;
|
||||
}
|
||||
return JSON.stringify(normalized);
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
return raw.replace('T', ' ').slice(0, 16);
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return '';
|
||||
}
|
||||
return raw.slice(0, 5);
|
||||
}
|
||||
|
||||
function formatDays(value) {
|
||||
const days = normalizeDayValues(value);
|
||||
return days
|
||||
.map(function (day) {
|
||||
return DAY_NAMES[day] || '';
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function formatScheduleRuleSummary(rule) {
|
||||
const normalized = normalizeScheduleRule(rule);
|
||||
if (!normalized) {
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
const leftParts = [];
|
||||
if (normalized.start_datetime && normalized.end_datetime) {
|
||||
leftParts.push('Dates ' + formatDateTime(normalized.start_datetime) + ' to ' + formatDateTime(normalized.end_datetime));
|
||||
} else {
|
||||
leftParts.push('Any date');
|
||||
}
|
||||
|
||||
const rightParts = [];
|
||||
const days = formatDays(normalized.days || []);
|
||||
if (days) {
|
||||
rightParts.push(days);
|
||||
}
|
||||
if (normalized.start_time && normalized.end_time) {
|
||||
rightParts.push(formatTime(normalized.start_time) + '-' + formatTime(normalized.end_time));
|
||||
}
|
||||
|
||||
if (rightParts.length) {
|
||||
return leftParts[0] + ': ' + rightParts.join(' ');
|
||||
}
|
||||
|
||||
return leftParts[0];
|
||||
}
|
||||
|
||||
function formatScheduleRulesSummary(value) {
|
||||
const rules = parseScheduleRules(value) || [];
|
||||
if (!rules.length) {
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
return rules.length === 1 ? '1 rule' : String(rules.length) + ' rules';
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
normalizeDateTimeValue: normalizeDateTimeValue,
|
||||
normalizeTimeValue: normalizeTimeValue,
|
||||
normalizeDayValues: normalizeDayValues,
|
||||
normalizeScheduleRule: normalizeScheduleRule,
|
||||
parseScheduleRules: parseScheduleRules,
|
||||
serializeScheduleRules: serializeScheduleRules,
|
||||
formatScheduleRuleSummary: formatScheduleRuleSummary,
|
||||
formatScheduleRulesSummary: formatScheduleRulesSummary
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// Error page renderer for the web UI.
|
||||
|
||||
const { renderView } = require('../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
function getErrorCopy(statusCode, message) {
|
||||
const normalizedStatusCode = Number(statusCode) || 500;
|
||||
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
|
||||
return {
|
||||
title: 'Access denied',
|
||||
errorTitle: 'Access denied',
|
||||
errorMessage: message || 'You do not have permission to access this area.',
|
||||
errorMessage: message || PERMISSION_DENIED_MESSAGE,
|
||||
backLabel: 'Back to dashboard'
|
||||
};
|
||||
}
|
||||
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
|
||||
return renderView('error/error', {
|
||||
title: String(errorOptions.title || copy.title || 'Error').trim(),
|
||||
active: '',
|
||||
messageVariant: 'primary',
|
||||
messageVariant: statusCode === 403 ? 'warning' : 'primary',
|
||||
currentUser: currentUser || null,
|
||||
statusCode: statusCode,
|
||||
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
|
||||
|
||||
+10
-8
@@ -19,26 +19,28 @@ module.exports = {
|
||||
renderPlaylistEditPage: require(routePath('signage', 'playlists', 'edit')),
|
||||
renderPlaylistSlideConfigPage: require(routePath('signage', 'playlists', 'slide-config')),
|
||||
renderApiSourcesPage: require(routePath('data-sources', 'api-sources', 'list')),
|
||||
renderApiSourceFormPage: require(routePath('data-sources', 'api-sources', 'add')),
|
||||
renderApiSourceAddPage: 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')),
|
||||
renderTimetableGroupsPage: require(routePath('data-sources', 'timetables', 'list')),
|
||||
renderTimetableGroupAddPage: require(routePath('data-sources', 'timetables', 'add')),
|
||||
renderTimetableGroupEditPage: require(routePath('data-sources', 'timetables', 'edit')),
|
||||
renderRssFeedsPage: require(routePath('data-sources', 'rss-feeds', 'list')),
|
||||
renderRssFeedFormPage: require(routePath('data-sources', 'rss-feeds', 'add')),
|
||||
renderRssFeedAddPage: require(routePath('data-sources', 'rss-feeds', 'add')),
|
||||
renderRssFeedEditPage: require(routePath('data-sources', 'rss-feeds', 'edit')),
|
||||
renderScreensPage: require(routePath('signage', 'screens', 'list')),
|
||||
renderScreenFormPage: require(routePath('signage', 'screens', 'add')),
|
||||
renderScreenEditPage: require(routePath('signage', 'screens', 'edit')),
|
||||
renderAnnouncementsPage: require(routePath('signage', 'announcements', 'list')),
|
||||
renderAnnouncementFormPage: require(routePath('signage', 'announcements', 'add')),
|
||||
renderAnnouncementAddPage: require(routePath('signage', 'announcements', 'add')),
|
||||
renderAnnouncementEditPage: require(routePath('signage', 'announcements', 'edit')),
|
||||
renderSlidesPage: require(routePath('signage', 'slides', 'list')),
|
||||
renderSlideFormPage: require(routePath('signage', 'slides', 'form')),
|
||||
renderSlideAddPage: require(routePath('signage', 'slides', 'add')),
|
||||
renderSlideEditPage: require(routePath('signage', 'slides', 'edit')),
|
||||
renderTemplatesPage: require(routePath('signage', 'templates', 'list')),
|
||||
renderTemplateFormPage: require(routePath('signage', 'templates', 'add')),
|
||||
renderTemplateAddPage: require(routePath('signage', 'templates', 'add')),
|
||||
renderTemplateEditPage: require(routePath('signage', 'templates', 'edit')),
|
||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeAddPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
||||
renderFontsPage: require(routePath('settings', 'fonts', 'list')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
|
||||
|
||||
@@ -146,6 +146,10 @@
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.min-w-0 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.sidebar-brand .brand-text.fw-light {
|
||||
margin: 0;
|
||||
}
|
||||
@@ -170,7 +174,8 @@
|
||||
.card-header .card-tools {
|
||||
position: absolute;
|
||||
right: 20px;
|
||||
top: 12px;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
}
|
||||
|
||||
.card-header .card-tools > .btn,
|
||||
@@ -526,6 +531,115 @@
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.screen-command-card .card-header {
|
||||
padding-top: 1rem;
|
||||
padding-bottom: 1rem;
|
||||
}
|
||||
|
||||
.screen-command-panel {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.2fr) minmax(0, 0.8fr);
|
||||
gap: 1rem;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.screen-command-panel-left {
|
||||
display: grid;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.screen-command-panel-right {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
padding: 0.9rem 1rem;
|
||||
border-left: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.screen-command-selector-field {
|
||||
display: grid;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.screen-command-selector-label {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.screen-command-summary {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
align-content: start;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.screen-command-summary strong {
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.screen-command-summary-meta {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.875rem;
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
.screen-command-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
width: fit-content;
|
||||
padding: 0.35rem 0.7rem;
|
||||
border-radius: 999px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.screen-command-pill.is-live {
|
||||
background: rgba(13, 110, 253, 0.12);
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.screen-command-pill.is-idle {
|
||||
background: rgba(108, 117, 125, 0.12);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.screen-command-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.screen-command-actions .btn {
|
||||
min-width: 8.75rem;
|
||||
}
|
||||
|
||||
.screen-command-actions .inline-form {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@media (max-width: 767.98px) {
|
||||
.screen-command-panel {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.screen-command-panel-right {
|
||||
padding: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.screen-command-actions {
|
||||
justify-content: stretch;
|
||||
}
|
||||
|
||||
.screen-command-actions .btn {
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.dashboard-screen-card-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
@@ -660,7 +774,19 @@
|
||||
border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
|
||||
}
|
||||
|
||||
.card-body.table-responsive > table.table > thead th {
|
||||
.playlist-items-table-responsive {
|
||||
overflow: hidden;
|
||||
border-bottom-left-radius: calc(var(--bs-border-radius) - 1px);
|
||||
border-bottom-right-radius: calc(var(--bs-border-radius) - 1px);
|
||||
}
|
||||
|
||||
.playlist-items-table-responsive > table.table {
|
||||
width: 100%;
|
||||
table-layout: auto;
|
||||
}
|
||||
|
||||
.card-body.table-responsive > table.table > thead th,
|
||||
.playlist-items-table-responsive > table.table > thead th {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 1;
|
||||
@@ -1563,6 +1689,35 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.template-field-card.collapsed-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-field-card.collapsed-card > .card-body {
|
||||
display: block !important;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.template-field-card.collapsed-card .card-header {
|
||||
border-bottom-left-radius: var(--bs-border-radius);
|
||||
border-bottom-right-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.template-field-body {
|
||||
min-width: 0;
|
||||
transition: opacity 0.5s ease;
|
||||
}
|
||||
|
||||
.template-field-card.collapsing-card .template-field-body,
|
||||
.template-field-card.collapsed-card .template-field-body {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.template-field-card.expanding-card .template-field-body {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.template-field-card .card-header,
|
||||
.region-item .card-header {
|
||||
background: var(--bs-tertiary-bg);
|
||||
@@ -1851,14 +2006,27 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-mismatch {
|
||||
border-color: var(--bs-border-color-translucent);
|
||||
background: rgba(var(--bs-secondary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-media {
|
||||
filter: saturate(0.85) grayscale(0.25);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-mismatch .playlist-slide-picker-media {
|
||||
filter: saturate(0.8) grayscale(0.45);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-title {
|
||||
color: var(--bs-body-secondary);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-mismatch .playlist-slide-picker-title {
|
||||
color: var(--bs-body-secondary);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1971,7 +2139,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
width: min(calc(100vw - 2rem), 56rem);
|
||||
max-height: calc(100vh - 0.25rem);
|
||||
height: calc(100vh - 2rem);
|
||||
max-height: calc(100vh - 2rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1981,6 +2150,61 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
|
||||
.slide-schedule-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.slide-schedule-content .slide-schedule-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.slide-schedule-content .slide-schedule-card form {
|
||||
display: flex;
|
||||
flex: 1 1 auto;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.slide-schedule-content .slide-schedule-card .card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.slide-schedule-content #schedule-rule-list {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
scrollbar-gutter: stable both-edges;
|
||||
}
|
||||
|
||||
.slide-schedule-content .schedule-rule-card .card-header {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.slide-schedule-content .schedule-rule-card.collapsed-card {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-schedule-content .schedule-rule-card.collapsed-card > .card-body {
|
||||
display: block !important;
|
||||
padding: 0 !important;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.slide-schedule-content .schedule-rule-card.collapsed-card .card-header {
|
||||
border-bottom-left-radius: var(--bs-border-radius);
|
||||
border-bottom-right-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.slide-schedule-content .schedule-rule-card .card-body {
|
||||
padding-top: 0.75rem;
|
||||
}
|
||||
|
||||
.actions,
|
||||
@@ -2044,7 +2268,8 @@ td[data-label="Actions"] > div {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td[data-label="Duration"] {
|
||||
td[data-label="Duration"],
|
||||
td[data-label="Config"] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
@@ -2125,6 +2350,7 @@ table.table > :not(caption) > * > * {
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
text-decoration: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.playlist-drag-handle-icon {
|
||||
@@ -2152,6 +2378,20 @@ table.table > :not(caption) > * > * {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.playlist-row-ghost {
|
||||
background: rgba(var(--bs-primary-rgb, 13, 110, 253), 0.08) !important;
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.playlist-row-dragging {
|
||||
box-shadow: 0 0.85rem 2rem rgba(15, 23, 42, 0.18);
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
.playlist-row-drag-layer {
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
table.table thead th.sortable {
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
|
||||
@@ -19,7 +19,123 @@
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var actionPath = '';
|
||||
try {
|
||||
actionPath = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionPath = String(form.action || '');
|
||||
}
|
||||
|
||||
if (!/\/delete(?:\/|$)/.test(actionPath)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to delete item.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
window.location.replace(response.url || '/login');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.replace(response.url || window.location.href);
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var status = Number(error && error.status);
|
||||
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to delete item.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to delete item.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initDeleteButtons() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var deleteButton = event.target.closest('[data-delete-action-url]');
|
||||
if (!deleteButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (deleteButton.disabled || (deleteButton.dataset && deleteButton.dataset.busy === 'true')) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var actionUrl = deleteButton.getAttribute('data-delete-action-url');
|
||||
if (!actionUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = deleteButton.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
deleteButton.dataset.busy = 'true';
|
||||
|
||||
fetch(actionUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to delete item.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
window.location.replace(response.url || '/login');
|
||||
return;
|
||||
}
|
||||
|
||||
window.location.replace(response.url || window.location.href);
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var status = Number(error && error.status);
|
||||
var variant = status === 403 ? 'warning' : status >= 400 && status < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to delete item.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to delete item.');
|
||||
}).finally(function () {
|
||||
delete deleteButton.dataset.busy;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -79,6 +195,191 @@
|
||||
}, true);
|
||||
}
|
||||
|
||||
function parseAsyncSaveResponseMessage(responseText) {
|
||||
var savedMessage = '';
|
||||
try {
|
||||
var responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
|
||||
var toastBody = responseDocument.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
return savedMessage;
|
||||
}
|
||||
|
||||
function getAsyncSaveActionField(form) {
|
||||
if (!form || !form.querySelector) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return form.querySelector('input[type="hidden"][name="save_action"]') || form.querySelector('input[type="hidden"][name="action"]');
|
||||
}
|
||||
|
||||
function isLoginRedirect(response) {
|
||||
if (!response || !response.redirected || !response.url) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(response.url, window.location.href).pathname === '/login';
|
||||
} catch (_error) {
|
||||
return /\/login(?:[?#]|$)/.test(String(response.url || ''));
|
||||
}
|
||||
}
|
||||
|
||||
async function submitAsyncSaveForm(form, event, options) {
|
||||
var settings = options || {};
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
try {
|
||||
if (typeof settings.beforeSubmit === 'function') {
|
||||
var beforeResult = await settings.beforeSubmit(form, event);
|
||||
if (beforeResult === false) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
var hiddenSaveAction = getAsyncSaveActionField(form);
|
||||
var formData = new FormData(form);
|
||||
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
||||
if (event && event.submitter && event.submitter.name) {
|
||||
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
|
||||
var hasFileValue = false;
|
||||
formData.forEach(function (value) {
|
||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||
hasFileValue = true;
|
||||
}
|
||||
});
|
||||
|
||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||
var body = isMultipart ? formData : new URLSearchParams();
|
||||
|
||||
if (!isMultipart) {
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
var response = await fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: Object.assign({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
}, isMultipart ? {} : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}),
|
||||
body: isMultipart ? body : body.toString(),
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || (settings.errorMessage || 'Unable to save changes.'));
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
window.location.replace(response.url || '/login');
|
||||
return true;
|
||||
}
|
||||
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(response.url || form.dataset.asyncSaveCloseUrl || window.location.href)
|
||||
: String(response.url || form.dataset.asyncSaveNewUrl || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.asyncSaveNewRedirect === 'response-url') {
|
||||
window.location.replace(String(response.url || form.dataset.asyncSaveNewUrl || window.location.href));
|
||||
return true;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var responseDocument = null;
|
||||
try {
|
||||
responseDocument = new DOMParser().parseFromString(responseText || '', 'text/html');
|
||||
} catch (_error) {
|
||||
responseDocument = null;
|
||||
}
|
||||
|
||||
if (typeof settings.afterSuccess === 'function') {
|
||||
await settings.afterSuccess({
|
||||
form: form,
|
||||
response: response,
|
||||
responseText: responseText,
|
||||
responseDocument: responseDocument,
|
||||
submitterValue: submitterValue
|
||||
});
|
||||
}
|
||||
|
||||
clearFormDirty(form);
|
||||
|
||||
var successMessage = typeof settings.getSuccessMessage === 'function'
|
||||
? settings.getSuccessMessage({
|
||||
form: form,
|
||||
response: response,
|
||||
responseText: responseText,
|
||||
responseDocument: responseDocument,
|
||||
submitterValue: submitterValue
|
||||
})
|
||||
: parseAsyncSaveResponseMessage(responseText);
|
||||
|
||||
showToast(successMessage || String(settings.fallbackSuccessMessage || 'Saved.'), 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'), variant);
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : (settings.errorMessage || 'Unable to save changes.'));
|
||||
}
|
||||
return false;
|
||||
} finally {
|
||||
delete form.dataset.busy;
|
||||
delete form.dataset.submitterValue;
|
||||
if (typeof settings.afterFinally === 'function') {
|
||||
settings.afterFinally(form);
|
||||
}
|
||||
var hiddenInput = getAsyncSaveActionField(form);
|
||||
if (hiddenInput) {
|
||||
hiddenInput.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
window.webAsyncSaveForm = window.webAsyncSaveForm || {};
|
||||
window.webAsyncSaveForm.submit = submitAsyncSaveForm;
|
||||
window.webAsyncSaveForm.parseResponseMessage = parseAsyncSaveResponseMessage;
|
||||
|
||||
function initAsyncCommandForms() {
|
||||
function getAnnouncementActionState(actionPath) {
|
||||
var isPlay = /\/play$/.test(actionPath);
|
||||
@@ -174,7 +475,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
var hiddenInput = getAsyncSaveActionField(form);
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
@@ -208,6 +509,9 @@
|
||||
if (typeof window.initTableSearches === 'function') {
|
||||
window.initTableSearches(targetElement);
|
||||
}
|
||||
if (targetElement.id === 'playlist-items-body' && typeof window.rebindPlaylistTableDrag === 'function') {
|
||||
window.rebindPlaylistTableDrag(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
@@ -311,7 +615,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var button = target.closest('button[name="save_action"]');
|
||||
var button = target.closest('button[name="save_action"], button[name="action"]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
@@ -330,129 +634,24 @@
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
var formData = new FormData(form);
|
||||
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
||||
if (event.submitter && event.submitter.name) {
|
||||
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
var hasFileValue = false;
|
||||
formData.forEach(function (value) {
|
||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||
hasFileValue = true;
|
||||
}
|
||||
});
|
||||
|
||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||
var body = isMultipart ? formData : new URLSearchParams();
|
||||
|
||||
if (!isMultipart) {
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: Object.assign({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
}, isMultipart ? {} : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}),
|
||||
body: isMultipart ? body : body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to save changes.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
var actionUrl = '';
|
||||
try {
|
||||
actionUrl = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionUrl = String(form.action || '');
|
||||
}
|
||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
|
||||
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
|
||||
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
if (shouldFollowRedirect && response.url) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url || window.location.href);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
var responseDocument = null;
|
||||
try {
|
||||
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = responseDocument.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
submitAsyncSaveForm(form, event, {
|
||||
fallbackSuccessMessage: 'Saved.',
|
||||
afterSuccess: function (context) {
|
||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||
var refreshTaskId = getResponseQueryValue(context.response.url || '', 'refresh_task_id');
|
||||
|
||||
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
||||
refreshSequence += 1;
|
||||
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
||||
} else if (refreshTargetSelector && responseDocument) {
|
||||
} else if (refreshTargetSelector && context.responseDocument) {
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
var nextTarget = context.responseDocument.querySelector(refreshTargetSelector);
|
||||
if (currentTarget && nextTarget) {
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved.', 'success');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to save changes.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to save changes.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
delete form.dataset.submitterValue;
|
||||
if (hiddenSaveAction) {
|
||||
hiddenSaveAction.value = '';
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
@@ -651,6 +850,7 @@
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDeleteButtons();
|
||||
initDirtyTracking();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
|
||||
@@ -6,20 +6,97 @@
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
var latestDashboardState = null;
|
||||
|
||||
function getClientMoveModalElements() {
|
||||
return {
|
||||
modal: document.getElementById('client-move-screen-modal'),
|
||||
form: document.getElementById('client-move-screen-form'),
|
||||
targetSelect: document.getElementById('client-move-screen-target'),
|
||||
connectionInput: document.querySelector('[data-client-move-connection-id]'),
|
||||
deviceInput: document.querySelector('[data-client-move-device-id]'),
|
||||
clientNameInput: document.querySelector('[data-client-move-client-name]')
|
||||
};
|
||||
}
|
||||
|
||||
function getClientMoveScreens() {
|
||||
if (latestDashboardState && Array.isArray(latestDashboardState.screens) && latestDashboardState.screens.length) {
|
||||
return latestDashboardState.screens.slice().sort(compareScreensByConnectedClients);
|
||||
}
|
||||
|
||||
var select = document.getElementById('client-move-screen-target');
|
||||
if (!select || !select.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return Array.prototype.slice.call(select.options).map(function (option) {
|
||||
return {
|
||||
slug: String(option.value || '').trim(),
|
||||
name: String(option.textContent || option.value || '').trim()
|
||||
};
|
||||
}).filter(function (screen) {
|
||||
return Boolean(screen && screen.slug);
|
||||
});
|
||||
}
|
||||
|
||||
function updateClientMoveModalFromRow(row) {
|
||||
var elements = getClientMoveModalElements();
|
||||
if (!elements.form || !elements.targetSelect || !row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var currentScreenSlug = String(row.getAttribute('data-client-screen-slug') || '').trim();
|
||||
var connectionId = String(row.getAttribute('data-client-key') || '').trim();
|
||||
var deviceId = String(row.getAttribute('data-client-device-id') || '').trim();
|
||||
var clientNameCell = row.querySelector('td[data-label="Client"] > div');
|
||||
var clientName = String(clientNameCell && clientNameCell.textContent || '').trim();
|
||||
var options = Array.prototype.slice.call(elements.targetSelect.options || []);
|
||||
|
||||
options.forEach(function (option) {
|
||||
option.disabled = false;
|
||||
if (String(option.value || '').trim() === currentScreenSlug) {
|
||||
option.disabled = true;
|
||||
}
|
||||
});
|
||||
|
||||
elements.form.action = currentScreenSlug ? '/clients/' + encodeURIComponent(currentScreenSlug) + '/commands' : '#';
|
||||
if (elements.connectionInput) {
|
||||
elements.connectionInput.value = connectionId;
|
||||
}
|
||||
if (elements.deviceInput) {
|
||||
elements.deviceInput.value = deviceId;
|
||||
}
|
||||
if (elements.clientNameInput) {
|
||||
elements.clientNameInput.value = clientName;
|
||||
}
|
||||
elements.targetSelect.value = '';
|
||||
if (elements.form.querySelector('button[type="submit"]')) {
|
||||
elements.form.querySelector('button[type="submit"]').disabled = false;
|
||||
}
|
||||
}
|
||||
function renderClientActionCell(client) {
|
||||
var paused = Boolean(client.paused);
|
||||
var pauseButtonClass = 'btn btn-sm btn-info';
|
||||
var pauseButtonIcon = paused ? 'bi-play-fill' : 'bi-pause-fill';
|
||||
var pauseButtonLabel = paused ? 'Resume' : 'Pause';
|
||||
var pauseButtonLabel = paused ? 'Resume client' : 'Pause client';
|
||||
var pauseButtonText = paused ? 'Resume' : 'Pause';
|
||||
var blackout = Boolean(client.blackout);
|
||||
var blackoutButtonClass = 'btn btn-sm ' + (blackout ? 'btn-success' : 'btn-secondary');
|
||||
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||
var blackoutButtonLabel = blackout ? 'Restore' : 'Blackout';
|
||||
var blackoutButtonLabel = blackout ? 'Restore client' : 'Blackout client';
|
||||
var blackoutButtonText = blackout ? 'Restore' : 'Blackout';
|
||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||
|
||||
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||
return [
|
||||
'<div class="actions justify-content-end">',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button></form>',
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide" title="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide" title="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause" aria-label="' + pauseButtonLabel + '" title="' + pauseButtonLabel + '"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonText + '</button></form>',
|
||||
'<form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + (blackout ? 'false' : 'true') + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout" aria-label="' + blackoutButtonLabel + '" title="' + blackoutButtonLabel + '"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonText + '</button></form>',
|
||||
'</div>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
function updateClientActionCell(cell, client) {
|
||||
@@ -35,6 +112,8 @@
|
||||
|
||||
var paused = Boolean(client.paused);
|
||||
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
||||
pauseButton.setAttribute('aria-label', paused ? 'Resume client' : 'Pause client');
|
||||
pauseButton.setAttribute('title', paused ? 'Resume client' : 'Pause client');
|
||||
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||
|
||||
var pauseForm = pauseButton.form;
|
||||
@@ -52,7 +131,9 @@
|
||||
|
||||
var reloadButton = cell.querySelector('button[data-action="reload"]');
|
||||
if (reloadButton) {
|
||||
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload';
|
||||
reloadButton.setAttribute('aria-label', 'Reload client');
|
||||
reloadButton.setAttribute('title', 'Reload client');
|
||||
reloadButton.innerHTML = '<i class="bi bi-arrow-repeat" aria-hidden="true"></i>';
|
||||
setButtonVariant(reloadButton, ['btn-danger', 'btn-success', 'btn-outline-secondary', 'btn-outline-dark', 'btn-outline-primary', 'btn-secondary'], 'btn-danger');
|
||||
var reloadForm = reloadButton.form;
|
||||
if (reloadForm) {
|
||||
@@ -74,6 +155,8 @@
|
||||
var blackout = Boolean(client.blackout);
|
||||
var blackoutButtonIcon = blackout ? 'bi-eye' : 'bi-eye-slash';
|
||||
setButtonVariant(blackoutButton, ['btn-success', 'btn-secondary'], blackout ? 'btn-success' : 'btn-secondary');
|
||||
blackoutButton.setAttribute('aria-label', blackout ? 'Restore client' : 'Blackout client');
|
||||
blackoutButton.setAttribute('title', blackout ? 'Restore client' : 'Blackout client');
|
||||
blackoutButton.innerHTML = '<i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + (blackout ? 'Restore' : 'Blackout');
|
||||
|
||||
var blackoutForm = blackoutButton.form;
|
||||
@@ -100,6 +183,7 @@
|
||||
}
|
||||
setButtonVariant(previousButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
|
||||
previousButton.setAttribute('aria-label', 'Previous slide');
|
||||
previousButton.setAttribute('title', 'Previous slide');
|
||||
var previousForm = previousButton.form;
|
||||
if (previousForm) {
|
||||
var previousCommandInput = previousForm.querySelector('input[name="command"]');
|
||||
@@ -120,6 +204,7 @@
|
||||
}
|
||||
setButtonVariant(nextButton, ['btn-outline-secondary', 'btn-success', 'btn-danger', 'btn-primary', 'btn-secondary', 'btn-warning'], 'btn-warning');
|
||||
nextButton.setAttribute('aria-label', 'Next slide');
|
||||
nextButton.setAttribute('title', 'Next slide');
|
||||
var nextForm = nextButton.form;
|
||||
if (nextForm) {
|
||||
var nextCommandInput = nextForm.querySelector('input[name="command"]');
|
||||
@@ -168,7 +253,7 @@
|
||||
var actionCell = hasActionsColumn ? '<td data-label="Actions">' + renderClientActionCell(client) + '</td>' : '';
|
||||
|
||||
return [
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
|
||||
'<tr data-client-key="' + escapeHtml(getClientRowKey(client)) + '" data-client-id="' + escapeHtml(client.clientId || '') + '" data-client-device-id="' + escapeHtml(client.deviceId || '') + '" data-client-screen-slug="' + escapeHtml(client.screen_slug || '') + '">',
|
||||
'<td data-label="Client"><div>' + clientName + '</div></td>',
|
||||
'<td data-label="Current Screen"><div>' + screenName + '</div></td>',
|
||||
'<td data-label="Current Slide">' + currentSlide + '</td>',
|
||||
@@ -328,6 +413,141 @@
|
||||
grid.innerHTML = screens.map(renderScreenTile).join('');
|
||||
}
|
||||
|
||||
function updateScreenCommandControls(state) {
|
||||
var select = document.getElementById('screen-command-select');
|
||||
if (!select) {
|
||||
return;
|
||||
}
|
||||
|
||||
var forms = Array.prototype.slice.call(document.querySelectorAll('[data-screen-command-form]'));
|
||||
var pill = document.querySelector('[data-screen-command-pill]');
|
||||
var nameNode = document.querySelector('[data-screen-command-name]');
|
||||
var metaNode = document.querySelector('[data-screen-command-meta]');
|
||||
var screens = Array.isArray(state && state.screens) ? state.screens : [];
|
||||
var screenBySlug = {};
|
||||
|
||||
screens.forEach(function (screen) {
|
||||
if (screen && screen.slug) {
|
||||
screenBySlug[String(screen.slug)] = screen;
|
||||
}
|
||||
});
|
||||
|
||||
if (!screens.length) {
|
||||
select.value = '';
|
||||
select.disabled = true;
|
||||
forms.forEach(function (form) {
|
||||
form.querySelectorAll('button, input').forEach(function (control) {
|
||||
control.disabled = true;
|
||||
});
|
||||
});
|
||||
if (pill) {
|
||||
pill.classList.remove('is-live');
|
||||
pill.classList.add('is-idle');
|
||||
pill.textContent = 'No screens';
|
||||
}
|
||||
if (nameNode) {
|
||||
nameNode.textContent = 'No target available';
|
||||
}
|
||||
if (metaNode) {
|
||||
metaNode.textContent = 'Create a screen before using screen-level commands.';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
select.disabled = false;
|
||||
if (!select.value || !screenBySlug[select.value]) {
|
||||
select.value = screens[0].slug || '';
|
||||
}
|
||||
|
||||
var selectedScreen = screenBySlug[select.value] || screens[0];
|
||||
var selectedSlug = String(selectedScreen && selectedScreen.slug || '').trim();
|
||||
var selectedClients = Array.isArray(state && state.clients) ? state.clients.filter(function (client) {
|
||||
return String(client && client.screen_slug || '').trim() === selectedSlug;
|
||||
}) : [];
|
||||
var connectionCount = selectedClients.length;
|
||||
var hasClients = connectionCount > 0;
|
||||
var allPaused = hasClients && selectedClients.every(function (client) {
|
||||
return Boolean(client && client.paused);
|
||||
});
|
||||
var allBlackout = hasClients && selectedClients.every(function (client) {
|
||||
return Boolean(client && client.blackout);
|
||||
});
|
||||
var connectionLabel = hasClients ? connectionCount + ' connected client' + (connectionCount === 1 ? '' : 's') : 'No clients connected';
|
||||
|
||||
if (pill) {
|
||||
pill.classList.toggle('is-live', hasClients);
|
||||
pill.classList.toggle('is-idle', !hasClients);
|
||||
pill.textContent = connectionLabel;
|
||||
}
|
||||
if (nameNode) {
|
||||
nameNode.textContent = String(selectedScreen && selectedScreen.name || 'Selected screen');
|
||||
}
|
||||
if (metaNode) {
|
||||
metaNode.textContent = 'Commands sent here target every client currently using this screen.';
|
||||
}
|
||||
|
||||
forms.forEach(function (form) {
|
||||
var command = String(form.getAttribute('data-screen-command-action') || '').trim().toLowerCase();
|
||||
var commandInput = form.querySelector('input[name="command"]');
|
||||
var button = form.querySelector('button[type="submit"]');
|
||||
if (commandInput) {
|
||||
if (command === 'pause') {
|
||||
commandInput.value = allPaused ? 'pause' : 'pause';
|
||||
var pauseStateInput = form.querySelector('input[name="paused"]');
|
||||
if (pauseStateInput) {
|
||||
pauseStateInput.value = allPaused ? 'false' : 'true';
|
||||
}
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi ' + (allPaused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (allPaused ? 'Resume screen' : 'Pause screen');
|
||||
}
|
||||
setButtonVariant(button, ['btn-success', 'btn-info', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
|
||||
form.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients on this screen?' : 'Pause all connected clients on this screen?');
|
||||
} else if (command === 'blackout') {
|
||||
commandInput.value = 'blackout';
|
||||
var blackoutStateInput = form.querySelector('input[name="blackout"]');
|
||||
if (blackoutStateInput) {
|
||||
blackoutStateInput.value = allBlackout ? 'false' : 'true';
|
||||
}
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi ' + (allBlackout ? 'bi-eye' : 'bi-eye-slash') + ' me-1" aria-hidden="true"></i>' + (allBlackout ? 'Restore screen' : 'Blackout screen');
|
||||
}
|
||||
setButtonVariant(button, ['btn-success', 'btn-secondary', 'btn-danger', 'btn-outline-secondary', 'btn-outline-dark'], allBlackout ? 'btn-success' : 'btn-secondary');
|
||||
form.setAttribute('data-confirm-message', allBlackout ? 'Restore all connected clients on this screen?' : 'Blackout all connected clients on this screen?');
|
||||
} else {
|
||||
commandInput.value = command || commandInput.value || '';
|
||||
}
|
||||
}
|
||||
form.action = selectedSlug ? '/clients/' + encodeURIComponent(selectedSlug) + '/commands' : '#';
|
||||
if (command === 'reload') {
|
||||
form.setAttribute('data-confirm-message', 'Reload selected screen?');
|
||||
if (button) {
|
||||
button.innerHTML = '<i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload screen';
|
||||
}
|
||||
}
|
||||
Array.prototype.slice.call(form.querySelectorAll('button, input')).forEach(function (control) {
|
||||
control.disabled = !selectedSlug;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function readScreenCommandStateFromDom() {
|
||||
var select = document.getElementById('screen-command-select');
|
||||
if (!select) {
|
||||
return { screens: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
screens: Array.prototype.slice.call(select.options || []).map(function (option) {
|
||||
return {
|
||||
slug: String(option.value || '').trim(),
|
||||
name: String(option.getAttribute('data-screen-name') || option.textContent || option.value || '').trim(),
|
||||
player_connection_count: Number(option.getAttribute('data-player-connection-count') || 0),
|
||||
playlist_name: String(option.getAttribute('data-playlist-name') || '').trim()
|
||||
};
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function updateDashboardQuickActions(state) {
|
||||
var pauseButton = document.getElementById('dashboard-pause-all-button');
|
||||
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
||||
@@ -378,8 +598,10 @@
|
||||
if (!state) {
|
||||
return;
|
||||
}
|
||||
latestDashboardState = state;
|
||||
updateStats(state);
|
||||
updateScreenGrid(state);
|
||||
updateScreenCommandControls(state);
|
||||
updateClientTable(state);
|
||||
updateDashboardQuickActions(state);
|
||||
}
|
||||
@@ -468,7 +690,98 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initClientMoveHandler() {
|
||||
var elements = getClientMoveModalElements();
|
||||
if (!elements.modal || !elements.form || !elements.targetSelect) {
|
||||
return;
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var moveButton = event.target && event.target.closest ? event.target.closest('button[data-action="move-screen"]') : null;
|
||||
if (!moveButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
var row = moveButton.closest ? moveButton.closest('tr[data-client-key]') : null;
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
updateClientMoveModalFromRow(row);
|
||||
});
|
||||
|
||||
elements.form.addEventListener('submit', function (event) {
|
||||
if (elements.form.dataset && elements.form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var targetScreenSlug = String(elements.targetSelect.value || '').trim();
|
||||
if (!targetScreenSlug) {
|
||||
event.preventDefault();
|
||||
window.alert('Choose a target screen.');
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
elements.form.dataset.busy = 'true';
|
||||
|
||||
var formData = new FormData(elements.form);
|
||||
var body = new URLSearchParams();
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
|
||||
fetch(elements.form.action, {
|
||||
method: (elements.form.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to move client.');
|
||||
try {
|
||||
var payload = JSON.parse(text);
|
||||
if (payload && (payload.error || payload.message)) {
|
||||
error = new Error(String(payload.error || payload.message));
|
||||
}
|
||||
} catch (_error) {
|
||||
// fall back to the raw text body
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
|
||||
window.pulseModal.hide(elements.modal);
|
||||
} else if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(elements.modal).hide();
|
||||
}
|
||||
return response.json().catch(function () {
|
||||
return { ok: true };
|
||||
});
|
||||
}).catch(function (error) {
|
||||
window.alert(error && error.message ? error.message : 'Unable to move client.');
|
||||
}).finally(function () {
|
||||
delete elements.form.dataset.busy;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.webHandleDashboardState = handleDashboardState;
|
||||
|
||||
initClientRenameHandler();
|
||||
initClientMoveHandler();
|
||||
var screenCommandSelect = document.getElementById('screen-command-select');
|
||||
if (screenCommandSelect) {
|
||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
||||
screenCommandSelect.addEventListener('change', function () {
|
||||
updateScreenCommandControls(latestDashboardState || readScreenCommandStateFromDom());
|
||||
});
|
||||
}
|
||||
}());
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
// 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);
|
||||
}());
|
||||
@@ -0,0 +1,121 @@
|
||||
// Minimal row editor for timetable groups.
|
||||
|
||||
(function () {
|
||||
var form = document.getElementById('timetable-group-form');
|
||||
var body = document.querySelector('[data-timetable-entries-body]');
|
||||
var addButton = document.querySelector('[data-add-timetable-entry]');
|
||||
var template = document.getElementById('timetable-entry-row-template');
|
||||
|
||||
if (!form || !body || !addButton || !template) {
|
||||
return;
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
|
||||
function formatDateTimeLocalValue(date) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
String(date.getFullYear()).padStart(4, '0'),
|
||||
'-',
|
||||
pad(date.getMonth() + 1),
|
||||
'-',
|
||||
pad(date.getDate()),
|
||||
'T',
|
||||
pad(date.getHours()),
|
||||
':',
|
||||
pad(date.getMinutes())
|
||||
].join('');
|
||||
}
|
||||
|
||||
function parseUtcDateTimeLocalValue(value) {
|
||||
var raw = String(value || '').trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var normalized = /(?:[zZ]|[+-]\d\d(?::?\d\d)?)$/.test(raw) ? raw : raw + 'Z';
|
||||
var date = new Date(normalized);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function toUtcDateTimeLocalValue(value) {
|
||||
var localDate = new Date(String(value || '').trim());
|
||||
return Number.isNaN(localDate.getTime()) ? '' : localDate.toISOString();
|
||||
}
|
||||
|
||||
function syncRowValuesToLocal(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
['entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
|
||||
var input = row.querySelector('[name="' + name + '"]');
|
||||
if (!input || input.dataset.timetableTimezoneSynced === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var localValue = formatDateTimeLocalValue(parseUtcDateTimeLocalValue(input.value));
|
||||
if (localValue) {
|
||||
input.value = localValue;
|
||||
}
|
||||
input.dataset.timetableTimezoneSynced = 'true';
|
||||
});
|
||||
}
|
||||
|
||||
function syncFormDataToUtc(formData) {
|
||||
var rows = body.querySelectorAll('[data-timetable-entry-row]');
|
||||
['entry_id[]', 'entry_title[]', 'entry_short_description[]', 'entry_start_datetime[]', 'entry_end_datetime[]'].forEach(function (name) {
|
||||
formData.delete(name);
|
||||
});
|
||||
|
||||
rows.forEach(function (row) {
|
||||
var idInput = row.querySelector('[name="entry_id[]"]');
|
||||
var titleInput = row.querySelector('[name="entry_title[]"]');
|
||||
var descriptionInput = row.querySelector('[name="entry_short_description[]"]');
|
||||
var startInput = row.querySelector('[name="entry_start_datetime[]"]');
|
||||
var endInput = row.querySelector('[name="entry_end_datetime[]"]');
|
||||
|
||||
formData.append('entry_id[]', idInput ? idInput.value : '');
|
||||
formData.append('entry_title[]', titleInput ? titleInput.value : '');
|
||||
formData.append('entry_short_description[]', descriptionInput ? descriptionInput.value : '');
|
||||
formData.append('entry_start_datetime[]', startInput ? toUtcDateTimeLocalValue(startInput.value) : '');
|
||||
formData.append('entry_end_datetime[]', endInput ? toUtcDateTimeLocalValue(endInput.value) : '');
|
||||
});
|
||||
}
|
||||
|
||||
function bindRemove(row) {
|
||||
var button = row.querySelector('[data-remove-timetable-entry]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
button.addEventListener('click', function () {
|
||||
row.remove();
|
||||
});
|
||||
}
|
||||
|
||||
function addRow() {
|
||||
var fragment = template.content.cloneNode(true);
|
||||
var row = fragment.querySelector('[data-timetable-entry-row]');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
bindRemove(row);
|
||||
body.appendChild(fragment);
|
||||
}
|
||||
|
||||
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
|
||||
bindRemove(row);
|
||||
syncRowValuesToLocal(row);
|
||||
});
|
||||
|
||||
form.addEventListener('formdata', function (event) {
|
||||
syncFormDataToUtc(event.formData);
|
||||
});
|
||||
|
||||
addButton.addEventListener('click', addRow);
|
||||
}());
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,410 @@
|
||||
(function () {
|
||||
// Playlist table drag sorting is isolated here so the playlist editor script
|
||||
// can stay focused on slide picking, schedule editing, and row updates.
|
||||
|
||||
function getDragEventPoint(event) {
|
||||
var touch = event.touches && event.touches[0] ? event.touches[0] : event.changedTouches && event.changedTouches[0] ? event.changedTouches[0] : null;
|
||||
|
||||
return {
|
||||
x: touch ? touch.clientX : event.clientX,
|
||||
y: touch ? touch.clientY : event.clientY
|
||||
};
|
||||
}
|
||||
|
||||
function measureDraggedRowWidths(row) {
|
||||
var metrics = {
|
||||
rowRect: row.getBoundingClientRect(),
|
||||
cellRects: []
|
||||
};
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell) {
|
||||
metrics.cellRects.push(cell.getBoundingClientRect());
|
||||
});
|
||||
|
||||
return metrics;
|
||||
}
|
||||
|
||||
function lockDraggedRowWidths(row, metrics) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rowRect = metrics && metrics.rowRect ? metrics.rowRect : row.getBoundingClientRect();
|
||||
row.style.width = rowRect.width + 'px';
|
||||
row.style.height = rowRect.height + 'px';
|
||||
row.style.boxSizing = 'border-box';
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell, index) {
|
||||
var cellRect = metrics && metrics.cellRects && metrics.cellRects[index] ? metrics.cellRects[index] : cell.getBoundingClientRect();
|
||||
cell.style.width = cellRect.width + 'px';
|
||||
cell.style.height = cellRect.height + 'px';
|
||||
cell.style.boxSizing = 'border-box';
|
||||
});
|
||||
}
|
||||
|
||||
function syncDraggedRowCellPadding(row, sourceRow) {
|
||||
if (!row || !sourceRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell, index) {
|
||||
var sourceCell = sourceRow.children[index];
|
||||
if (!sourceCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
var sourceStyle = window.getComputedStyle(sourceCell);
|
||||
cell.style.paddingLeft = sourceStyle.paddingLeft;
|
||||
cell.style.paddingRight = sourceStyle.paddingRight;
|
||||
cell.style.paddingTop = sourceStyle.paddingTop;
|
||||
cell.style.paddingBottom = sourceStyle.paddingBottom;
|
||||
});
|
||||
}
|
||||
|
||||
function unlockDraggedRowWidths(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.style.width = '';
|
||||
row.style.height = '';
|
||||
row.style.boxSizing = '';
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell) {
|
||||
cell.style.width = '';
|
||||
cell.style.height = '';
|
||||
cell.style.boxSizing = '';
|
||||
});
|
||||
}
|
||||
|
||||
function animateRowReorder(previousRects, excludedRows) {
|
||||
var excluded = Array.isArray(excludedRows) ? excludedRows : [];
|
||||
|
||||
if (!previousRects || typeof previousRects.forEach !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
previousRects.forEach(function (previousRect, row) {
|
||||
var nextRect;
|
||||
var deltaY;
|
||||
var previousTransition;
|
||||
|
||||
if (!row || excluded.indexOf(row) !== -1 || !row.parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
nextRect = row.getBoundingClientRect();
|
||||
deltaY = previousRect.top - nextRect.top;
|
||||
|
||||
if (!deltaY) {
|
||||
return;
|
||||
}
|
||||
|
||||
previousTransition = row.style.transition;
|
||||
row.style.transition = 'none';
|
||||
row.style.transform = 'translate3d(0, ' + deltaY + 'px, 0)';
|
||||
row.offsetHeight;
|
||||
row.style.transition = 'transform 180ms ease';
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
if (!row.parentNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.style.transform = '';
|
||||
|
||||
var cleanup = function () {
|
||||
row.style.transition = previousTransition;
|
||||
row.removeEventListener('transitionend', cleanup);
|
||||
row.removeEventListener('transitioncancel', cleanup);
|
||||
};
|
||||
|
||||
row.addEventListener('transitionend', cleanup, { once: true });
|
||||
row.addEventListener('transitioncancel', cleanup, { once: true });
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initPlaylistTableDrag(options) {
|
||||
var tbody = options && options.tbody ? options.tbody : null;
|
||||
var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {};
|
||||
var rowDragState = null;
|
||||
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tbody.dataset && tbody.dataset.playlistTableDragBound === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (tbody.dataset) {
|
||||
tbody.dataset.playlistTableDragBound = 'true';
|
||||
}
|
||||
|
||||
function getRows() {
|
||||
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])'));
|
||||
}
|
||||
|
||||
function getDragHandle(target) {
|
||||
return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null;
|
||||
}
|
||||
|
||||
function prepareGhostRow(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.setAttribute('data-playlist-ghost-row', 'true');
|
||||
row.setAttribute('aria-hidden', 'true');
|
||||
row.classList.add('playlist-row-ghost');
|
||||
row.style.pointerEvents = 'none';
|
||||
|
||||
Array.prototype.forEach.call(row.querySelectorAll('[id]'), function (node) {
|
||||
node.removeAttribute('id');
|
||||
});
|
||||
|
||||
Array.prototype.forEach.call(row.querySelectorAll('input, select, textarea, button'), function (control) {
|
||||
control.disabled = true;
|
||||
control.setAttribute('tabindex', '-1');
|
||||
if (control.tagName === 'BUTTON') {
|
||||
control.setAttribute('type', 'button');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createDragLayer(row, width) {
|
||||
var table = row.ownerDocument.createElement('table');
|
||||
var tbodyElement = row.ownerDocument.createElement('tbody');
|
||||
|
||||
table.className = 'playlist-row-drag-layer';
|
||||
table.style.position = 'fixed';
|
||||
table.style.top = '0';
|
||||
table.style.left = '0';
|
||||
table.style.margin = '0';
|
||||
table.style.borderCollapse = 'collapse';
|
||||
table.style.borderSpacing = '0';
|
||||
table.style.tableLayout = 'fixed';
|
||||
table.style.pointerEvents = 'none';
|
||||
table.style.zIndex = '1080';
|
||||
table.style.width = width + 'px';
|
||||
|
||||
tbodyElement.appendChild(row);
|
||||
table.appendChild(tbodyElement);
|
||||
row.ownerDocument.body.appendChild(table);
|
||||
|
||||
return table;
|
||||
}
|
||||
|
||||
function positionDragLayer(layer, metrics, point) {
|
||||
if (!layer || !metrics || !point) {
|
||||
return;
|
||||
}
|
||||
|
||||
layer.style.transform = 'translate3d(' + (point.x - metrics.offsetX) + 'px, ' + (point.y - metrics.offsetY) + 'px, 0)';
|
||||
}
|
||||
|
||||
function beginRowDrag() {
|
||||
if (!rowDragState || rowDragState.active) {
|
||||
return;
|
||||
}
|
||||
|
||||
var row = rowDragState.row;
|
||||
var metrics = rowDragState.metrics;
|
||||
var placeholder = row.cloneNode(true);
|
||||
var rowRect = metrics.rowRect;
|
||||
|
||||
prepareGhostRow(placeholder);
|
||||
lockDraggedRowWidths(placeholder, metrics);
|
||||
syncDraggedRowCellPadding(placeholder, row);
|
||||
placeholder.classList.add('playlist-row-ghost');
|
||||
tbody.insertBefore(placeholder, row);
|
||||
|
||||
rowDragState.placeholder = placeholder;
|
||||
rowDragState.dragLayer = createDragLayer(row, metrics.rowRect.width);
|
||||
rowDragState.active = true;
|
||||
rowDragState.metrics.offsetX = rowDragState.startX - rowRect.left;
|
||||
rowDragState.metrics.offsetY = rowDragState.startY - rowRect.top;
|
||||
|
||||
lockDraggedRowWidths(row, metrics);
|
||||
syncDraggedRowCellPadding(row, placeholder);
|
||||
|
||||
row.classList.add('playlist-row-dragging');
|
||||
row.style.pointerEvents = 'none';
|
||||
row.setAttribute('aria-grabbed', 'true');
|
||||
|
||||
positionDragLayer(rowDragState.dragLayer, metrics, rowDragState.currentPoint || {
|
||||
x: rowDragState.startX,
|
||||
y: rowDragState.startY
|
||||
});
|
||||
}
|
||||
|
||||
function repositionGhostRow(clientY) {
|
||||
var rows = getRows();
|
||||
var insertBeforeRow = null;
|
||||
var previousRects = new Map();
|
||||
var moved = false;
|
||||
|
||||
rows.forEach(function (row) {
|
||||
if (row !== rowDragState.placeholder) {
|
||||
previousRects.set(row, row.getBoundingClientRect());
|
||||
}
|
||||
});
|
||||
|
||||
for (var index = 0; index < rows.length; index += 1) {
|
||||
var candidateRow = rows[index];
|
||||
var rect = candidateRow.getBoundingClientRect();
|
||||
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
insertBeforeRow = candidateRow;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertBeforeRow) {
|
||||
if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) {
|
||||
tbody.insertBefore(rowDragState.placeholder, insertBeforeRow);
|
||||
moved = true;
|
||||
}
|
||||
} else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) {
|
||||
tbody.appendChild(rowDragState.placeholder);
|
||||
moved = true;
|
||||
}
|
||||
|
||||
if (moved) {
|
||||
animateRowReorder(previousRects, [rowDragState.row, rowDragState.placeholder]);
|
||||
}
|
||||
}
|
||||
|
||||
function teardownRowDrag() {
|
||||
if (!rowDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (rowDragState.supportsPointerEvents) {
|
||||
document.removeEventListener('pointermove', handleRowDragMove);
|
||||
document.removeEventListener('pointerup', handleRowDragEnd);
|
||||
document.removeEventListener('pointercancel', handleRowDragEnd);
|
||||
} else {
|
||||
document.removeEventListener('mousemove', handleRowDragMove);
|
||||
document.removeEventListener('mouseup', handleRowDragEnd);
|
||||
document.removeEventListener('touchmove', handleRowDragMove);
|
||||
document.removeEventListener('touchend', handleRowDragEnd);
|
||||
document.removeEventListener('touchcancel', handleRowDragEnd);
|
||||
}
|
||||
|
||||
if (rowDragState.active) {
|
||||
if (rowDragState.placeholder && rowDragState.placeholder.parentNode) {
|
||||
rowDragState.placeholder.parentNode.insertBefore(rowDragState.row, rowDragState.placeholder);
|
||||
rowDragState.placeholder.parentNode.removeChild(rowDragState.placeholder);
|
||||
} else if (rowDragState.row.parentNode !== tbody) {
|
||||
tbody.appendChild(rowDragState.row);
|
||||
}
|
||||
|
||||
if (rowDragState.dragLayer && rowDragState.dragLayer.parentNode) {
|
||||
rowDragState.dragLayer.parentNode.removeChild(rowDragState.dragLayer);
|
||||
}
|
||||
|
||||
rowDragState.row.classList.remove('playlist-row-dragging');
|
||||
rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents;
|
||||
rowDragState.row.removeAttribute('aria-grabbed');
|
||||
unlockDraggedRowWidths(rowDragState.row);
|
||||
onOrderChanged();
|
||||
}
|
||||
|
||||
rowDragState = null;
|
||||
}
|
||||
|
||||
function handleRowDragMove(event) {
|
||||
if (!rowDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
var point = getDragEventPoint(event);
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
rowDragState.currentPoint = point;
|
||||
|
||||
if (!rowDragState.active) {
|
||||
var threshold = typeof rowDragState.threshold === 'number' ? rowDragState.threshold : 3;
|
||||
|
||||
if (Math.abs(point.x - rowDragState.startX) < threshold && Math.abs(point.y - rowDragState.startY) < threshold) {
|
||||
return;
|
||||
}
|
||||
|
||||
beginRowDrag();
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
positionDragLayer(rowDragState.dragLayer, rowDragState.metrics, point);
|
||||
repositionGhostRow(point.y);
|
||||
}
|
||||
|
||||
function handleRowDragEnd() {
|
||||
teardownRowDrag();
|
||||
}
|
||||
|
||||
function handleRowDragStart(event) {
|
||||
var handle = getDragHandle(event.target);
|
||||
var row = handle ? handle.closest('tr[data-playlist-slide-row]') : null;
|
||||
var point = getDragEventPoint(event);
|
||||
|
||||
if (!row || rowDragState) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.type === 'mousedown' && event.button !== 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!point) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
rowDragState = {
|
||||
row: row,
|
||||
metrics: measureDraggedRowWidths(row),
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
currentPoint: point,
|
||||
threshold: 3,
|
||||
active: false,
|
||||
previousPointerEvents: row.style.pointerEvents || '',
|
||||
placeholder: null,
|
||||
dragLayer: null,
|
||||
supportsPointerEvents: typeof window !== 'undefined' && typeof window.PointerEvent === 'function'
|
||||
};
|
||||
|
||||
if (rowDragState.supportsPointerEvents) {
|
||||
document.addEventListener('pointermove', handleRowDragMove, { passive: false });
|
||||
document.addEventListener('pointerup', handleRowDragEnd);
|
||||
document.addEventListener('pointercancel', handleRowDragEnd);
|
||||
} else {
|
||||
document.addEventListener('mousemove', handleRowDragMove);
|
||||
document.addEventListener('mouseup', handleRowDragEnd);
|
||||
document.addEventListener('touchmove', handleRowDragMove, { passive: false });
|
||||
document.addEventListener('touchend', handleRowDragEnd);
|
||||
document.addEventListener('touchcancel', handleRowDragEnd);
|
||||
}
|
||||
}
|
||||
|
||||
// The drag handle is intentionally isolated from the rest of the playlist
|
||||
// editor so it can be swapped or removed without touching schedule logic.
|
||||
if (typeof window !== 'undefined' && window.PointerEvent) {
|
||||
tbody.addEventListener('pointerdown', handleRowDragStart);
|
||||
} else {
|
||||
tbody.addEventListener('mousedown', handleRowDragStart);
|
||||
tbody.addEventListener('touchstart', handleRowDragStart, { passive: false });
|
||||
}
|
||||
}
|
||||
|
||||
window.initPlaylistTableDrag = initPlaylistTableDrag;
|
||||
}());
|
||||
@@ -1,4 +1,4 @@
|
||||
// Schedule region helpers for editor previews and defaults.
|
||||
// Timetable region helpers for editor previews and defaults.
|
||||
|
||||
(function () {
|
||||
var registry = window.pulseRegionTypes;
|
||||
@@ -27,13 +27,13 @@
|
||||
};
|
||||
}
|
||||
|
||||
function getScheduleGroups() {
|
||||
return Array.isArray(window.initialData && window.initialData.scheduleGroups) ? window.initialData.scheduleGroups : [];
|
||||
function getTimetableGroups() {
|
||||
return Array.isArray(window.initialData && window.initialData.timetableGroups) ? window.initialData.timetableGroups : [];
|
||||
}
|
||||
|
||||
function getGroupById(groupId, groups) {
|
||||
var normalizedId = Number(groupId || 0);
|
||||
return (Array.isArray(groups) ? groups : getScheduleGroups()).find(function (group) {
|
||||
return (Array.isArray(groups) ? groups : getTimetableGroups()).find(function (group) {
|
||||
return Number(group.id) === normalizedId;
|
||||
}) || null;
|
||||
}
|
||||
@@ -81,6 +81,12 @@
|
||||
return isUpcoming(entry, now);
|
||||
});
|
||||
|
||||
if (!entries.length && mode === 'upcoming') {
|
||||
entries = getEntries(groupId, groups).filter(function (entry) {
|
||||
return isLive(entry, now);
|
||||
});
|
||||
}
|
||||
|
||||
return entries.slice(0, Math.max(1, Number(maxItems || 5)));
|
||||
}
|
||||
|
||||
@@ -112,7 +118,7 @@
|
||||
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),
|
||||
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_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)),
|
||||
@@ -137,18 +143,18 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function getCurrentSelection(regionId, card, existingContent, scheduleGroups) {
|
||||
function getCurrentSelection(regionId, card, existingContent, timetableGroups) {
|
||||
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);
|
||||
var timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + regionId + '"]') : null;
|
||||
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + regionId + '"]') : null;
|
||||
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_max_items_' + regionId + '"]') : null;
|
||||
var groupId = timetableGroupInput ? timetableGroupInput.value : (current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id));
|
||||
var displayMode = timetableDisplayModeInput ? timetableDisplayModeInput.value : String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming';
|
||||
var maxItems = Math.max(1, Number(timetableMaxItemsInput ? timetableMaxItemsInput.value : current.max_items || 5));
|
||||
var group = getGroupById(groupId, timetableGroups);
|
||||
var entries = getVisibleEntries(groupId, displayMode, maxItems, timetableGroups);
|
||||
if (!entries.length) {
|
||||
entries = getEntries(groupId, scheduleGroups).slice(0, maxItems);
|
||||
entries = getEntries(groupId, timetableGroups).slice(0, maxItems);
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -162,17 +168,17 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var scheduleGroups = Array.isArray(context.scheduleGroups) ? context.scheduleGroups : [];
|
||||
var timetableGroups = Array.isArray(context.timetableGroups) ? context.timetableGroups : [];
|
||||
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 currentGroupId = current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_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);
|
||||
var currentGroup = getGroupById(currentGroupId, timetableGroups);
|
||||
var currentEntries = getVisibleEntries(currentGroupId, currentDisplayMode, currentMaxItems, timetableGroups);
|
||||
if (!currentEntries.length) {
|
||||
currentEntries = getEntries(currentGroupId, scheduleGroups).slice(0, currentMaxItems);
|
||||
currentEntries = getEntries(currentGroupId, timetableGroups).slice(0, currentMaxItems);
|
||||
}
|
||||
var groupOptions = scheduleGroups.map(function (group) {
|
||||
var groupOptions = timetableGroups.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('');
|
||||
@@ -181,7 +187,7 @@
|
||||
'<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 class="template-field-actions"><span class="chip">Timetable</span></div>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid gap-3">' +
|
||||
'<div class="editor-holder" data-region-id="' + region.id + '">' +
|
||||
@@ -190,33 +196,32 @@
|
||||
'</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">' +
|
||||
'<label class="form-label" for="region_timetable_group_id_' + region.id + '">Timetable group</label>' +
|
||||
'<select id="region_timetable_group_id_' + region.id + '" name="region_timetable_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">' +
|
||||
'<label class="form-label" for="region_timetable_display_mode_' + region.id + '">Display mode</label>' +
|
||||
'<select id="region_timetable_display_mode_' + region.id + '" name="region_timetable_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) + '" />' +
|
||||
'<label class="form-label" for="region_timetable_max_items_' + region.id + '">Max items</label>' +
|
||||
'<input id="region_timetable_max_items_' + region.id + '" type="number" min="1" step="1" name="region_timetable_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="text-body-secondary small">Use the selected group and display mode to choose which timetable entry fields are available.</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>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -241,7 +246,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var groups = getScheduleGroups();
|
||||
var groups = getTimetableGroups();
|
||||
var current = getCurrentSelection(regionId, card, {}, groups);
|
||||
panel.innerHTML = renderSchedulePlaceholderChips(current.group, current.entries);
|
||||
}
|
||||
@@ -255,7 +260,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (!target.closest('select[name^="region_schedule_group_id_"], select[name^="region_schedule_display_mode_"], input[name^="region_schedule_max_items_"]')) {
|
||||
if (!target.closest('select[name^="region_timetable_group_id_"], select[name^="region_timetable_display_mode_"], input[name^="region_timetable_max_items_"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -270,15 +275,15 @@
|
||||
return {
|
||||
region: context.region,
|
||||
current: context.current || {},
|
||||
scheduleGroups: Array.isArray(context.scheduleGroups) ? context.scheduleGroups : []
|
||||
timetableGroups: Array.isArray(context.timetableGroups) ? context.timetableGroups : []
|
||||
};
|
||||
}
|
||||
|
||||
function buildPreviewRenderContext(region, card, existingContent, scheduleGroups) {
|
||||
function buildPreviewRenderContext(region, card, existingContent, timetableGroups) {
|
||||
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 timetableGroupInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]') : null;
|
||||
var timetableDisplayModeInput = card && card.querySelector ? card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]') : null;
|
||||
var timetableMaxItemsInput = card && card.querySelector ? card.querySelector('input[name="region_timetable_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;
|
||||
@@ -287,33 +292,30 @@
|
||||
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,
|
||||
timetable_group_id: timetableGroupInput ? timetableGroupInput.value || current.timetable_group_id : current.timetable_group_id,
|
||||
display_mode: timetableDisplayModeInput ? timetableDisplayModeInput.value || current.display_mode : current.display_mode,
|
||||
max_items: timetableMaxItemsInput ? timetableMaxItemsInput.value || current.max_items : current.max_items,
|
||||
existingContent: existingContent || {},
|
||||
scheduleGroups: Array.isArray(scheduleGroups) ? scheduleGroups : []
|
||||
timetableGroups: Array.isArray(timetableGroups) ? timetableGroups : []
|
||||
};
|
||||
}
|
||||
|
||||
function renderPreview(region, regionContent, context) {
|
||||
var groups = context && context.scheduleGroups ? context.scheduleGroups : [];
|
||||
var groups = context && context.timetableGroups ? context.timetableGroups : [];
|
||||
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 groupId = regionContent && regionContent.timetable_group_id !== undefined ? regionContent.timetable_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 timetable" 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 || {}, {
|
||||
return '<div class="slide-preview-region timetable" 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="timetable-region-entry" data-timetable-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 || {},
|
||||
@@ -323,8 +325,8 @@
|
||||
}).join('') + '</div>';
|
||||
}
|
||||
|
||||
registry.register('schedule', {
|
||||
label: 'Schedule',
|
||||
registry.register('timetable', {
|
||||
label: 'Timetable',
|
||||
getDefaultStyle: getDefaultStyle,
|
||||
getDefaultRegionSize: function () {
|
||||
return { width: 520, height: 280 };
|
||||
|
||||
@@ -8,7 +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 timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var placeholderChips = window.placeholderChips || {};
|
||||
@@ -460,10 +460,10 @@ export function createSlideFormRegionHelpers(options) {
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentScheduleConfig(region) {
|
||||
function getCurrentTimetableConfig(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),
|
||||
timetable_group_id: current.timetable_group_id === undefined || current.timetable_group_id === null || current.timetable_group_id === '' ? '' : Number(current.timetable_group_id),
|
||||
display_mode: String(current.display_mode || 'upcoming').trim().toLowerCase() || 'upcoming',
|
||||
max_items: Math.max(1, Number(current.max_items || 5))
|
||||
};
|
||||
@@ -480,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' || region.region_type === 'schedule' ? (existingContent[region.region_key] || {}) : getCurrentRegionValue(region),
|
||||
current: region.region_type === 'rss' || region.region_type === 'api' || region.region_type === 'time-date' || region.region_type === 'timetable' ? (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) : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : getCurrentRssConfig(region),
|
||||
config: region.region_type === 'api' ? getCurrentApiConfig(region) : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : getCurrentRssConfig(region),
|
||||
style: getCurrentTextStyle(region),
|
||||
fontSize: getCurrentTextStyle(region).font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -503,8 +503,8 @@ export function createSlideFormRegionHelpers(options) {
|
||||
}).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),
|
||||
scheduleGroups: scheduleGroups
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'schedule' ? scheduleGroups : apiSources);
|
||||
timetableGroups: timetableGroups
|
||||
}, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
@@ -521,7 +521,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
disableAudio: currentContent.disable_audio,
|
||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'schedule' ? getCurrentScheduleConfig(region) : rssConfig,
|
||||
config: region.region_type === 'api' ? apiConfig : region.region_type === 'timetable' ? getCurrentTimetableConfig(region) : rssConfig,
|
||||
style: textStyle,
|
||||
fontSize: textStyle.font_size,
|
||||
feedOptions: rssFeeds.map(function (feed) {
|
||||
@@ -538,7 +538,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
}).join('')
|
||||
: buildLimitedPlaceholderChipMarkup(getApiFieldList(apiConfig.source_id, apiItemsPath), 'No JSON fields available.'),
|
||||
sampleDataPreview: buildApiSampleDataMarkup(apiConfig.source_id, apiConfig.item_number, apiItemsPath),
|
||||
scheduleGroups: scheduleGroups
|
||||
timetableGroups: timetableGroups
|
||||
};
|
||||
}
|
||||
|
||||
@@ -548,13 +548,89 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return module.renderEditorCard(buildRegionEditorCardContext(region));
|
||||
return makeRegionCardCollapsible(module.renderEditorCard(buildRegionEditorCardContext(region)), region);
|
||||
}
|
||||
|
||||
function makeRegionCardCollapsible(html, region) {
|
||||
if (typeof html !== 'string' || !html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
if (html.indexOf('data-lte-toggle="card-collapse"') !== -1) {
|
||||
return html;
|
||||
}
|
||||
|
||||
var cardHtml = html.replace(
|
||||
'class="card card-outline card-secondary admin-form-card template-field-card mb-3"',
|
||||
'class="card card-widget card-outline card-secondary admin-form-card template-field-card mb-3"'
|
||||
);
|
||||
|
||||
if (cardHtml === html) {
|
||||
return html;
|
||||
}
|
||||
|
||||
var bodyOpenMatch = cardHtml.match(/<div class="card-body([^"]*)">/);
|
||||
if (bodyOpenMatch) {
|
||||
var bodyClassTokens = String(bodyOpenMatch[1] || '')
|
||||
.trim()
|
||||
.split(/\s+/)
|
||||
.filter(Boolean);
|
||||
var wrapperGapTokens = bodyClassTokens.filter(function (token) {
|
||||
return /^gap-\d+$/i.test(token);
|
||||
});
|
||||
var wrapperPaddingTokens = bodyClassTokens.filter(function (token) {
|
||||
return /^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
|
||||
});
|
||||
var bodyClassTokensWithoutGrid = bodyClassTokens.filter(function (token) {
|
||||
return token !== 'd-grid' && !/^gap-\d+$/i.test(token) && !/^(?:p|pt|pb|ps|pe|px|py)-\d+$/i.test(token);
|
||||
});
|
||||
var bodyClassAttribute = ' class="card-body p-0' + (bodyClassTokensWithoutGrid.length ? ' ' + bodyClassTokensWithoutGrid.join(' ') : '') + '"';
|
||||
var wrapperClassTokens = ['template-field-body', 'd-grid']
|
||||
.concat(wrapperPaddingTokens)
|
||||
.concat(wrapperGapTokens);
|
||||
var wrapperClassAttribute = ' class="' + wrapperClassTokens.filter(Boolean).join(' ') + '"';
|
||||
|
||||
cardHtml = cardHtml.replace(bodyOpenMatch[0], '<div' + bodyClassAttribute + '><div' + wrapperClassAttribute + '>');
|
||||
|
||||
var lastCloseIndex = cardHtml.lastIndexOf('</div>');
|
||||
if (lastCloseIndex !== -1) {
|
||||
var bodyCloseIndex = cardHtml.lastIndexOf('</div>', lastCloseIndex - 1);
|
||||
if (bodyCloseIndex !== -1) {
|
||||
cardHtml = cardHtml.slice(0, bodyCloseIndex) + '</div>' + cardHtml.slice(bodyCloseIndex);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var collapseLabel = region && region.label ? String(region.label) : 'region';
|
||||
var collapseButton = '' +
|
||||
'<button type="button" class="btn btn-tool p-0 ms-auto text-body-secondary template-field-collapse" data-lte-toggle="card-collapse" aria-label="Collapse ' + escapeHtml(collapseLabel) + '" title="Collapse ' + escapeHtml(collapseLabel) + '">' +
|
||||
'<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>' +
|
||||
'<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>' +
|
||||
'<span class="visually-hidden">Collapse ' + escapeHtml(collapseLabel) + '</span>' +
|
||||
'</button>';
|
||||
|
||||
var bodyMarkerIndex = cardHtml.indexOf('<div class="card-body');
|
||||
if (bodyMarkerIndex === -1) {
|
||||
return cardHtml;
|
||||
}
|
||||
|
||||
var actionsMarker = '<div class="template-field-actions">';
|
||||
if (cardHtml.indexOf(actionsMarker) !== -1) {
|
||||
return cardHtml.replace(/(<div class="template-field-actions">[\s\S]*?)<\/div>/, '$1' + collapseButton + '</div>');
|
||||
}
|
||||
|
||||
var headerCloseIndex = cardHtml.lastIndexOf('</div>', bodyMarkerIndex);
|
||||
if (headerCloseIndex === -1) {
|
||||
return cardHtml;
|
||||
}
|
||||
|
||||
return cardHtml.slice(0, headerCloseIndex) + collapseButton + cardHtml.slice(headerCloseIndex);
|
||||
}
|
||||
|
||||
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 : region.region_type === 'schedule' ? scheduleGroups : apiSources);
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
}
|
||||
|
||||
var current = existingContent[region.region_key];
|
||||
@@ -604,14 +680,15 @@ 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 if (region.region_type === 'timetable') {
|
||||
var timetableGroupInput = card.querySelector('select[name="region_timetable_group_id_' + region.id + '"]');
|
||||
var timetableDisplayModeInput = card.querySelector('select[name="region_timetable_display_mode_' + region.id + '"]');
|
||||
var timetableMaxItemsInput = card.querySelector('input[name="region_timetable_max_items_' + region.id + '"]');
|
||||
var currentSchedule = getCurrentTimetableConfig(region);
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || currentSchedule.value || '');
|
||||
content.timetable_group_id = timetableGroupInput ? timetableGroupInput.value : currentSchedule.timetable_group_id;
|
||||
content.display_mode = timetableDisplayModeInput ? timetableDisplayModeInput.value : currentSchedule.display_mode;
|
||||
content.max_items = timetableMaxItemsInput ? timetableMaxItemsInput.value : currentSchedule.max_items;
|
||||
} else {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
}
|
||||
@@ -622,7 +699,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 : region.region_type === 'schedule' ? scheduleGroups : apiSources);
|
||||
return module.buildPreviewRenderContext(region, card, existingContent, region.region_type === 'rss' ? rssFeeds : region.region_type === 'timetable' ? timetableGroups : apiSources);
|
||||
}
|
||||
|
||||
var textStyle = getCurrentTextStyle(region);
|
||||
@@ -639,10 +716,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,
|
||||
timetable_group_id: previewContent.timetable_group_id,
|
||||
display_mode: previewContent.display_mode,
|
||||
max_items: previewContent.max_items,
|
||||
scheduleGroups: scheduleGroups,
|
||||
timetableGroups: timetableGroups,
|
||||
font_size: textStyle.font_size,
|
||||
font_color: textStyle.font_color,
|
||||
font_family: textStyle.font_family
|
||||
|
||||
@@ -21,7 +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 timetableGroups = Array.isArray(slideEditorData.timetableGroups) ? slideEditorData.timetableGroups : [];
|
||||
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
|
||||
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
|
||||
var existingContent = slideEditorData.existingContent || {};
|
||||
@@ -37,7 +37,6 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
|
||||
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
|
||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||
var submitting = false;
|
||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||
var uploadMaxLabel = '100 MB';
|
||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||
@@ -120,7 +119,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
existingContent: existingContent,
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
scheduleGroups: scheduleGroups,
|
||||
timetableGroups: timetableGroups,
|
||||
defaultFontSize: DEFAULT_FONT_SIZE,
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
@@ -643,6 +642,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>';
|
||||
}).join('') : '<div class="muted">This template has no regions.</div>';
|
||||
|
||||
if (window.adminlte && typeof window.adminlte.CardWidget === 'function') {
|
||||
templateFields.querySelectorAll('.card-widget[data-region-id]').forEach(function (card) {
|
||||
window.adminlte.CardWidget.getOrCreateInstance(card);
|
||||
});
|
||||
}
|
||||
|
||||
templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
|
||||
var regionId = card.getAttribute('data-region-id');
|
||||
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null;
|
||||
@@ -744,92 +749,25 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
requestPreviewRender();
|
||||
}
|
||||
|
||||
slideForm.addEventListener('submit', async function (event) {
|
||||
if (submitting) {
|
||||
slideForm.addEventListener('submit', function (event) {
|
||||
if (!window.webAsyncSaveForm || typeof window.webAsyncSaveForm.submit !== 'function') {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
var slideFormUrl = new URL(slideForm.action, window.location.href);
|
||||
var isCreateForm = slideFormUrl.pathname === '/slides';
|
||||
var submitterValue = event.submitter && event.submitter.name === 'save_action'
|
||||
? String(event.submitter.value || '').trim().toLowerCase()
|
||||
: '';
|
||||
submitting = true;
|
||||
try {
|
||||
if (hasPendingRegionImageUpload()) {
|
||||
throw new Error('Wait for the image upload to finish before saving.');
|
||||
}
|
||||
await slideFormEditorController.syncEditors();
|
||||
var formData = new FormData(slideForm);
|
||||
if (event.submitter && event.submitter.name) {
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
var response = await fetch(slideForm.action, {
|
||||
method: (slideForm.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
},
|
||||
body: formData,
|
||||
credentials: 'same-origin'
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(await response.text() || 'Unable to save slide.');
|
||||
}
|
||||
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action)
|
||||
: String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action);
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
|
||||
if (isCreateForm) {
|
||||
try {
|
||||
if (response.url) {
|
||||
if (regionMediaController) {
|
||||
regionMediaController.clearPendingUploadCleanupPaths();
|
||||
}
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
// Fall through and show a toast if the redirect URL cannot be resolved.
|
||||
window.webAsyncSaveForm.submit(slideForm, event, {
|
||||
beforeSubmit: async function () {
|
||||
if (hasPendingRegionImageUpload()) {
|
||||
throw new Error('Wait for the image upload to finish before saving.');
|
||||
}
|
||||
slideForm.dataset.dirty = 'false';
|
||||
showToast('Saved slide.', 'success');
|
||||
return;
|
||||
}
|
||||
|
||||
var responseText = await response.text();
|
||||
var savedMessage = '';
|
||||
try {
|
||||
var doc = new DOMParser().parseFromString(responseText || '', 'text/html');
|
||||
var toastBody = doc.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
await slideFormEditorController.syncEditors();
|
||||
},
|
||||
fallbackSuccessMessage: 'Saved slide.',
|
||||
afterSuccess: function () {
|
||||
if (regionMediaController) {
|
||||
regionMediaController.clearPendingUploadCleanupPaths();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved slide.', 'success');
|
||||
slideForm.dataset.dirty = 'false';
|
||||
if (regionMediaController) {
|
||||
regionMediaController.clearPendingUploadCleanupPaths();
|
||||
}
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error && error.message ? error.message : 'Unable to save slide.', variant);
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : 'Unable to save slide.');
|
||||
}
|
||||
} finally {
|
||||
submitting = false;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
slideForm.addEventListener('input', function () {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
(function (root) {
|
||||
function getToastTitle(variant) {
|
||||
function getToastTitle(variant, message) {
|
||||
var text = String(message || '').trim();
|
||||
if (/\b(?:deleted|removed)\.?$/i.test(text)) {
|
||||
return 'Deleted';
|
||||
}
|
||||
|
||||
var textVariant = String(variant || '').trim().toLowerCase();
|
||||
if (textVariant === 'danger') {
|
||||
return 'Error';
|
||||
@@ -142,6 +147,7 @@
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
var text = String(message || '').trim();
|
||||
var rules = [
|
||||
{ variant: 'danger', patterns: [/\b(?:deleted|removed)\.?\s*$/i, /\b(?:deleted|removed)\b/i] },
|
||||
{ variant: 'danger', patterns: [/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i, /\bdelete\b.*\b(?:before|first)\b/i, /\bstill (?:in use|linked|assigned|used)\b/i] },
|
||||
{ variant: 'warning', patterns: [/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i] }
|
||||
];
|
||||
@@ -195,7 +201,7 @@
|
||||
toast.setAttribute('data-toast-created-at', String(Date.now()));
|
||||
var toastVariant = getMessageVariant(text, variant);
|
||||
toast.setAttribute('data-toast-variant', toastVariant);
|
||||
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
|
||||
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant, text), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
setToastVariant(toast, toastVariant);
|
||||
|
||||
@@ -226,8 +232,13 @@
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
|
||||
var bodyText = (toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '';
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant(bodyText, 'info');
|
||||
setToastVariant(toast, existingVariant);
|
||||
var title = toast.querySelector('.toast-header .me-auto');
|
||||
if (title) {
|
||||
title.textContent = getToastTitle(existingVariant, bodyText);
|
||||
}
|
||||
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
|
||||
-2
File diff suppressed because one or more lines are too long
@@ -12,7 +12,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
|
||||
app.get('/account', function (req, res) {
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', req.query.return_url ? String(req.query.return_url) : ''));
|
||||
res.send(pages.renderAccountPage(req.currentUser, req.query.message ? String(req.query.message) : '', '/dashboard'));
|
||||
});
|
||||
|
||||
app.post('/account/name', async function (req, res, next) {
|
||||
@@ -32,7 +32,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
res.redirect('/account?message=' + encodeURIComponent('Name updated.'));
|
||||
res.redirect('/dashboard?message=' + encodeURIComponent('Name updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -68,7 +68,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
res.redirect('/dashboard?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -1,12 +1,16 @@
|
||||
// Admin client command routes for connected screens.
|
||||
|
||||
const { commitDeviceBinding } = require('#src/player/onboarding');
|
||||
|
||||
module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const isClientNameAvailable = deps.isClientNameAvailable;
|
||||
const withClientNameReservation = deps.withClientNameReservation;
|
||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||
const playerPublicBaseUrl = String(deps.playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
app.post('/clients/:slug/commands', requirePermission('clients.allow'), async function (req, res, next) {
|
||||
@@ -138,6 +142,101 @@ module.exports = function registerScreenCommandRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
if (command === 'moveclient') {
|
||||
const deviceId = String((req.body && (req.body.deviceId || req.body.clientId)) || req.query.deviceId || req.query.clientId || '').trim();
|
||||
const clientName = String((req.body && req.body.clientName) || req.query.clientName || '').trim();
|
||||
const targetScreenSlug = String((req.body && (req.body.targetScreenSlug || req.body.screenSlug)) || req.query.targetScreenSlug || req.query.screenSlug || '').trim();
|
||||
|
||||
if (!deviceId) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
if (!targetScreenSlug) {
|
||||
return res.status(400).json({ error: 'Target screen is required' });
|
||||
}
|
||||
|
||||
const [currentRows] = await pool.query(
|
||||
`SELECT d.client_name, s.slug AS current_screen_slug
|
||||
FROM d_onboarding_devices d
|
||||
LEFT JOIN d_screens s ON s.id = d.screen_id
|
||||
WHERE d.device_id = ?
|
||||
LIMIT 1`,
|
||||
[deviceId]
|
||||
);
|
||||
const onboardingRow = currentRows[0] || null;
|
||||
const resolvedClientName = String(clientName || onboardingRow && onboardingRow.client_name || '').trim();
|
||||
const currentScreenSlug = String(onboardingRow && onboardingRow.current_screen_slug || '').trim();
|
||||
|
||||
if (!resolvedClientName) {
|
||||
return res.status(400).json({ error: 'Client name is required' });
|
||||
}
|
||||
|
||||
if (currentScreenSlug && currentScreenSlug === targetScreenSlug) {
|
||||
return res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
deviceId: deviceId,
|
||||
clientName: resolvedClientName,
|
||||
targetScreenSlug: targetScreenSlug,
|
||||
ok: true,
|
||||
unchanged: true
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof commitDeviceBinding !== 'function') {
|
||||
return res.status(500).json({ error: 'Client binding is unavailable.' });
|
||||
}
|
||||
|
||||
let liveConnections = [];
|
||||
try {
|
||||
const [screenSlugs] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
const liveResults = await Promise.all((screenSlugs || []).map(async function (row) {
|
||||
const screenSlug = String(row && row.slug ? row.slug : '').trim();
|
||||
if (!screenSlug || typeof getScreenConnections !== 'function') {
|
||||
return [];
|
||||
}
|
||||
try {
|
||||
const liveResponse = await getScreenConnections(screenSlug);
|
||||
return Array.isArray(liveResponse && liveResponse.connections) ? liveResponse.connections : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}));
|
||||
liveConnections = liveResults.flat();
|
||||
} catch (_error) {
|
||||
liveConnections = [];
|
||||
}
|
||||
|
||||
const status = await commitDeviceBinding(pool, deviceId, resolvedClientName, targetScreenSlug, isClientNameAvailable, liveConnections);
|
||||
const targetPlayerRecord = typeof common.fetchScreenPlayerRecord === 'function'
|
||||
? await common.fetchScreenPlayerRecord(pool, targetScreenSlug)
|
||||
: null;
|
||||
const targetBaseUrl = String(targetPlayerRecord && targetPlayerRecord.public_base_url || playerPublicBaseUrl || '').trim().replace(/\/$/, '');
|
||||
const targetPlayerUrl = targetBaseUrl ? `${targetBaseUrl}/screen/${encodeURIComponent(targetScreenSlug)}` : `/screen/${encodeURIComponent(targetScreenSlug)}`;
|
||||
|
||||
await forwardPlayerCommand(slug, {
|
||||
command: 'redirect',
|
||||
url: targetPlayerUrl
|
||||
}, connectionId || deviceId || undefined);
|
||||
|
||||
if (typeof broadcastDashboardState === 'function') {
|
||||
await broadcastDashboardState();
|
||||
}
|
||||
|
||||
return res.json({
|
||||
screen: screenRows[0],
|
||||
screenSlug: slug,
|
||||
command: command,
|
||||
connectionId: connectionId || null,
|
||||
deviceId: deviceId,
|
||||
clientName: status ? status.client_name : resolvedClientName,
|
||||
targetScreenSlug: targetScreenSlug,
|
||||
playerUrl: targetPlayerUrl,
|
||||
ok: true
|
||||
});
|
||||
}
|
||||
|
||||
const commandPayload = req.body && typeof req.body === 'object' && !Array.isArray(req.body)
|
||||
? Object.assign({}, req.body, { command: command })
|
||||
: { command: command };
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { loadFontLibrary } = require('../../lib/media/font-library');
|
||||
const { loadFontLibrary } = require('#src/web/lib/media/font-library');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
module.exports = function registerContentRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -27,12 +28,39 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const hasAnyPermission = deps.hasAnyPermission;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
async function fetchSlideFormData() {
|
||||
const data = await common.fetchTemplatesData(pool);
|
||||
const rssData = await common.fetchRssFeedsData(pool);
|
||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
||||
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
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
|
||||
return Object.assign(data, rssData, apiData, timetableData, {
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableData.timetableGroups || [],
|
||||
fontLibrary: loadFontLibrary(deps.uploadDir)
|
||||
});
|
||||
}
|
||||
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerContentRoutes requires the content route dependencies.');
|
||||
}
|
||||
@@ -68,7 +96,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -271,25 +299,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
app.get('/slides/new', requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
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
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
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));
|
||||
const data = await fetchSlideFormData();
|
||||
res.send(pages.renderSlideAddPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -302,25 +313,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
slide.inUse = Boolean(await getSlideDeleteBlockMessage(pool, slide));
|
||||
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
|
||||
});
|
||||
}) : [];
|
||||
const rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||
const items = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, feed.id)
|
||||
: [];
|
||||
return Object.assign({}, feed, { items: items.map(function (item) {
|
||||
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||
}) });
|
||||
}));
|
||||
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));
|
||||
const data = await fetchSlideFormData();
|
||||
res.send(pages.renderSlideEditPage(data, slide, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -484,7 +478,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(null, 'create', req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
|
||||
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -536,7 +530,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
template.inUse = (await fetchSlidesByTemplateId(template.id)).length > 0;
|
||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
||||
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -642,7 +636,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.get('/canvas-sizes/new', requirePermission('canvas-sizes.create'), function (req, res) {
|
||||
res.send(pages.renderCanvasSizeFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
|
||||
@@ -1,172 +1,10 @@
|
||||
// Admin data-source route registration for API and RSS sources.
|
||||
// Root data-source redirect only.
|
||||
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
module.exports = function registerDataSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
const { hasAnyPermission } = require('#src/rbac');
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
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');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
function slideUsesDataSourceField(content, fieldName, targetId) {
|
||||
const normalizedTargetId = Number(targetId);
|
||||
if (!Number.isFinite(normalizedTargetId) || !content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(content)) {
|
||||
return content.some(function (item) {
|
||||
return slideUsesDataSourceField(item, fieldName, normalizedTargetId);
|
||||
});
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(content, fieldName) && Number(content[fieldName]) === normalizedTargetId) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Object.keys(content).some(function (key) {
|
||||
return slideUsesDataSourceField(content[key], fieldName, normalizedTargetId);
|
||||
});
|
||||
}
|
||||
|
||||
async function getDataSourceDeleteBlockMessage(fieldName, dataSourceId, label) {
|
||||
const isInUse = await getDataSourceInUse(fieldName, dataSourceId);
|
||||
if (isInUse) {
|
||||
return 'This ' + label + ' is still used by one or more slides.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function getDataSourceInUse(fieldName, dataSourceId) {
|
||||
const [slides] = await pool.query('SELECT id, content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (slideUsesDataSourceField(content, fieldName, dataSourceId)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
async function getDataSourceUsageMaps() {
|
||||
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;
|
||||
if (!content || typeof content !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
|
||||
const sourceId = Number(value.source_id);
|
||||
if (Number.isFinite(sourceId)) {
|
||||
apiSourceIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
|
||||
const feedId = Number(value.feed_id);
|
||||
if (Number.isFinite(feedId)) {
|
||||
rssFeedIds.add(feedId);
|
||||
}
|
||||
}
|
||||
|
||||
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]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return {
|
||||
apiSourceIds: apiSourceIds,
|
||||
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.');
|
||||
}
|
||||
|
||||
const dataSourceTasks = createDataSourceTaskService({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
});
|
||||
|
||||
async function sendRefreshTaskState(req, res, sourceType, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = await dataSourceTasks.getTaskStatusById(taskId);
|
||||
const expectedKey = sourceType + '-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
app.get('/data-sources', function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
@@ -177,9 +15,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
|
||||
return res.redirect('/login');
|
||||
}
|
||||
|
||||
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, ['timetables.read', 'rss-feeds.read', 'api-sources.read'])) {
|
||||
if (hasAnyPermission(req.currentUser, ['timetables.read'])) {
|
||||
return res.redirect('/data-sources/timetables');
|
||||
}
|
||||
if (hasAnyPermission(req.currentUser, ['rss-feeds.read'])) {
|
||||
return res.redirect('/data-sources/rss-feeds');
|
||||
@@ -187,666 +25,9 @@ module.exports = function registerDataSourceRoutes(app, deps) {
|
||||
return res.redirect('/data-sources/api-sources');
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.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.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageMaps = await getDataSourceUsageMaps();
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||
inUse: usageMaps.apiSourceIds.has(Number(apiSource.id))
|
||||
});
|
||||
});
|
||||
res.send(pages.renderApiSourcesPage({
|
||||
apiSources: apiSources,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
|
||||
res.send(pages.renderApiSourceFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'API source created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} 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/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const inUse = await getDataSourceInUse('source_id', apiSource.id);
|
||||
|
||||
res.send(pages.renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
inUse: inUse
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, 'api-source', Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + apiSource.id,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} 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/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const blockMessage = await getDataSourceDeleteBlockMessage('source_id', apiSource.id, 'API source');
|
||||
if (blockMessage) {
|
||||
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.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.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageMaps = await getDataSourceUsageMaps();
|
||||
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
inUse: usageMaps.rssFeedIds.has(Number(rssFeed.id))
|
||||
});
|
||||
});
|
||||
res.send(pages.renderRssFeedsPage({
|
||||
rssFeeds: rssFeeds,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
|
||||
res.send(pages.renderRssFeedFormPage(null, 'create', req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + result.insertId,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'RSS feed created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} 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/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const inUse = await getDataSourceInUse('feed_id', rssFeed.id);
|
||||
|
||||
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||
: [];
|
||||
|
||||
res.send(pages.renderRssFeedEditPage(Object.assign({}, rssFeed, {
|
||||
feedUrl: rssFeed.feed_url,
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit,
|
||||
inUse: inUse
|
||||
}), {
|
||||
pulledItems: pulledItems,
|
||||
pullError: ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, 'rss-feed', Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + rssFeed.id,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} 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/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const blockMessage = await getDataSourceDeleteBlockMessage('feed_id', rssFeed.id, 'RSS feed');
|
||||
if (blockMessage) {
|
||||
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
|
||||
} catch (error) {
|
||||
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);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,24 +1,15 @@
|
||||
// Admin manage routes for screens, playlists, slides, and templates.
|
||||
// Admin manage routes for screens and commands.
|
||||
|
||||
module.exports = function registerManageRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
|
||||
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
|
||||
const fetchPlaylistCanvasSignature = deps.fetchPlaylistCanvasSignature;
|
||||
const getCanvasSignature = deps.getCanvasSignature;
|
||||
const normalizeScheduleMode = deps.normalizeScheduleMode;
|
||||
const parseDateTimeLocal = deps.parseDateTimeLocal;
|
||||
const parseTimeLocal = deps.parseTimeLocal;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||
const getScreenDeleteBlockMessage = deps.getScreenDeleteBlockMessage;
|
||||
const getScreenConnections = deps.getScreenConnections;
|
||||
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
||||
const forwardPlayerCommand = deps.forwardPlayerCommand;
|
||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
||||
const requirePermission = deps.requirePermission;
|
||||
@@ -83,462 +74,6 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist created.'
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
|
||||
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
|
||||
|
||||
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
||||
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
|
||||
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
|
||||
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
|
||||
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
||||
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
||||
const scheduleStartTimes = readArrayField(req.body, ['schedule_start_time[]', 'schedule_start_time']);
|
||||
const scheduleEndTimes = readArrayField(req.body, ['schedule_end_time[]', 'schedule_end_time']);
|
||||
const scheduleDaysJsonValues = readArrayField(req.body, ['schedule_days_json[]', 'schedule_days_json']);
|
||||
|
||||
if (durations.length && durations.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist slide data is invalid.');
|
||||
}
|
||||
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist slide data is invalid.');
|
||||
}
|
||||
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist schedule data is invalid.');
|
||||
}
|
||||
|
||||
const normalizedSlides = [];
|
||||
const seenSlideIds = new Set();
|
||||
for (let i = 0; i < slideIds.length; i += 1) {
|
||||
const slideId = Number(slideIds[i]);
|
||||
if (!Number.isInteger(slideId) || slideId <= 0) {
|
||||
return res.status(400).send('Invalid slide selection.');
|
||||
}
|
||||
if (seenSlideIds.has(slideId)) {
|
||||
return res.status(400).send('A slide can only be added to a playlist once.');
|
||||
}
|
||||
seenSlideIds.add(slideId);
|
||||
|
||||
const durationRaw = Number(durations[i]);
|
||||
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
|
||||
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
|
||||
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
|
||||
|
||||
let scheduleStartDatetime = null;
|
||||
let scheduleEndDatetime = null;
|
||||
let scheduleStartTime = null;
|
||||
let scheduleEndTime = null;
|
||||
let scheduleDaysJson = null;
|
||||
|
||||
if (scheduleMode === 'dates') {
|
||||
scheduleStartDatetime = parseDateTimeLocal(scheduleStartDateTimes[i]);
|
||||
scheduleEndDatetime = parseDateTimeLocal(scheduleEndDateTimes[i]);
|
||||
if (!scheduleStartDatetime || !scheduleEndDatetime) {
|
||||
return res.status(400).send('Start and end datetimes are required for date scheduling.');
|
||||
}
|
||||
if (scheduleEndDatetime < scheduleStartDatetime) {
|
||||
return res.status(400).send('End datetime must be after start datetime.');
|
||||
}
|
||||
} else if (scheduleMode === 'times') {
|
||||
scheduleStartTime = parseTimeLocal(scheduleStartTimes[i]);
|
||||
scheduleEndTime = parseTimeLocal(scheduleEndTimes[i]);
|
||||
if (!scheduleStartTime || !scheduleEndTime) {
|
||||
return res.status(400).send('Start and end times are required for time scheduling.');
|
||||
}
|
||||
|
||||
let scheduleDays = [];
|
||||
try {
|
||||
const parsedDays = JSON.parse(String(scheduleDaysJsonValues[i] || '[]'));
|
||||
scheduleDays = Array.isArray(parsedDays) ? parsedDays : [];
|
||||
} catch (_error) {
|
||||
scheduleDays = [];
|
||||
}
|
||||
scheduleDays = scheduleDays
|
||||
.map(function (value) { return Number(value); })
|
||||
.filter(function (value) { return Number.isInteger(value) && value >= 0 && value <= 6; });
|
||||
if (!scheduleDays.length) {
|
||||
return res.status(400).send('Select at least one day for time scheduling.');
|
||||
}
|
||||
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
|
||||
}
|
||||
|
||||
normalizedSlides.push({
|
||||
slideId,
|
||||
position: i,
|
||||
durationSeconds,
|
||||
useVideoDuration,
|
||||
scheduleMode,
|
||||
scheduleStartDatetime,
|
||||
scheduleEndDatetime,
|
||||
scheduleStartTime,
|
||||
scheduleEndTime,
|
||||
scheduleDaysJson
|
||||
});
|
||||
}
|
||||
|
||||
if (normalizedSlides.length) {
|
||||
const [slides] = await connection.query(
|
||||
`SELECT sl.id, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM c_slides sl
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
WHERE sl.id IN (?)`,
|
||||
[normalizedSlides.map(function (item) { return item.slideId; })]
|
||||
);
|
||||
if (slides.length !== normalizedSlides.length) {
|
||||
return res.status(400).send('One or more selected slides no longer exist.');
|
||||
}
|
||||
const signatures = Array.from(new Set(
|
||||
slides
|
||||
.map(function (slide) { return getCanvasSignature(slide.canvas_width, slide.canvas_height); })
|
||||
.filter(Boolean)
|
||||
));
|
||||
if (signatures.length > 1) {
|
||||
return res.status(400).send('All playlist slides must share the same canvas size.');
|
||||
}
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.beginTransaction();
|
||||
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
|
||||
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
await connection.query(
|
||||
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
playlist.id,
|
||||
item.slideId,
|
||||
item.position,
|
||||
item.durationSeconds,
|
||||
item.useVideoDuration,
|
||||
item.scheduleMode,
|
||||
item.scheduleStartDatetime,
|
||||
item.scheduleEndDatetime,
|
||||
item.scheduleStartTime,
|
||||
item.scheduleEndTime,
|
||||
item.scheduleDaysJson,
|
||||
actorId,
|
||||
actorId
|
||||
]
|
||||
);
|
||||
}
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
|
||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const slideId = Number(req.body.slide_id);
|
||||
if (!slideId) {
|
||||
return res.status(400).send('Slide is required.');
|
||||
}
|
||||
const playlistCanvasSignature = await fetchPlaylistCanvasSignature(pool, playlist.id);
|
||||
if (playlistCanvasSignature === 'mismatch') {
|
||||
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
|
||||
}
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
const slideCanvasSignature = getCanvasSignature(slide.canvas_width, slide.canvas_height);
|
||||
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
||||
}
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE c_playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[durationSeconds, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide duration updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const direction = String(req.body.direction || '').toLowerCase();
|
||||
if (direction !== 'up' && direction !== 'down') {
|
||||
return res.status(400).send('Invalid move direction.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
|
||||
const currentIndex = orderedSlides.findIndex(function (item) {
|
||||
return Number(item.id) === playlistSlideId;
|
||||
});
|
||||
if (currentIndex === -1) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
}
|
||||
|
||||
const currentSlide = orderedSlides[currentIndex];
|
||||
const swapSlide = orderedSlides[swapIndex];
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
let playlistSlide = (data.playlistSlides || []).find(function (item) {
|
||||
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
|
||||
});
|
||||
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
const scheduleMode = typeof req.query.schedule_mode === 'string' && req.query.schedule_mode ? String(req.query.schedule_mode) : String(playlistSlide && playlistSlide.schedule_mode || 'always');
|
||||
const scheduleStartDatetime = typeof req.query.schedule_start_datetime === 'string' ? String(req.query.schedule_start_datetime) : (playlistSlide && playlistSlide.schedule_start_datetime) || null;
|
||||
const scheduleEndDatetime = typeof req.query.schedule_end_datetime === 'string' ? String(req.query.schedule_end_datetime) : (playlistSlide && playlistSlide.schedule_end_datetime) || null;
|
||||
const scheduleStartTime = typeof req.query.schedule_start_time === 'string' ? String(req.query.schedule_start_time) : (playlistSlide && playlistSlide.schedule_start_time) || null;
|
||||
const scheduleEndTime = typeof req.query.schedule_end_time === 'string' ? String(req.query.schedule_end_time) : (playlistSlide && playlistSlide.schedule_end_time) || null;
|
||||
const scheduleDaysJson = typeof req.query.schedule_days_json === 'string' ? String(req.query.schedule_days_json) : (playlistSlide && playlistSlide.schedule_days_json) || '[]';
|
||||
const scheduleDays = common.parseJsonSafe(scheduleDaysJson) || [];
|
||||
|
||||
playlistSlide = Object.assign({}, playlistSlide || {}, {
|
||||
id: playlistSlide ? playlistSlide.id : 0,
|
||||
schedule_mode: scheduleMode,
|
||||
schedule_days_json: scheduleDaysJson,
|
||||
schedule_days: scheduleDays,
|
||||
schedule_start_datetime: scheduleStartDatetime,
|
||||
schedule_end_datetime: scheduleEndDatetime,
|
||||
schedule_start_time: scheduleStartTime,
|
||||
schedule_end_time: scheduleEndTime
|
||||
});
|
||||
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const rowKey = String(req.body.row_key || '').trim();
|
||||
if (rowKey) {
|
||||
return res.status(400).send('Schedule changes from the playlist editor are staged until you click Save changes.');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
let scheduleMode = normalizeScheduleMode(req.body.schedule_mode);
|
||||
let scheduleStartDatetime = null;
|
||||
let scheduleEndDatetime = null;
|
||||
let scheduleStartTime = null;
|
||||
let scheduleEndTime = null;
|
||||
let scheduleDaysJson = null;
|
||||
|
||||
const hasDateRange = Boolean(req.body.schedule_start_datetime && req.body.schedule_end_datetime);
|
||||
const hasTimeRange = Boolean(req.body.schedule_start_time && req.body.schedule_end_time);
|
||||
const hasSelectedDays = Boolean(readArrayField(req.body, ['schedule_days', 'schedule_days[]']).length);
|
||||
|
||||
if (scheduleMode === 'dates' && !hasDateRange) {
|
||||
scheduleMode = 'always';
|
||||
} else if (scheduleMode === 'times' && (!hasTimeRange || !hasSelectedDays)) {
|
||||
scheduleMode = 'always';
|
||||
}
|
||||
|
||||
if (scheduleMode === 'dates') {
|
||||
scheduleStartDatetime = parseDateTimeLocal(req.body.schedule_start_datetime);
|
||||
scheduleEndDatetime = parseDateTimeLocal(req.body.schedule_end_datetime);
|
||||
if (!scheduleStartDatetime || !scheduleEndDatetime) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartDatetime = null;
|
||||
scheduleEndDatetime = null;
|
||||
} else if (scheduleEndDatetime < scheduleStartDatetime) {
|
||||
return res.status(400).send('End datetime must be after start datetime.');
|
||||
}
|
||||
} else if (scheduleMode === 'times') {
|
||||
scheduleStartTime = parseTimeLocal(req.body.schedule_start_time);
|
||||
scheduleEndTime = parseTimeLocal(req.body.schedule_end_time);
|
||||
const scheduleDays = readArrayField(req.body, ['schedule_days', 'schedule_days[]']).map(function (value) {
|
||||
return Number(value);
|
||||
}).filter(function (value) {
|
||||
return Number.isInteger(value) && value >= 0 && value <= 6;
|
||||
});
|
||||
if (!scheduleStartTime || !scheduleEndTime) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartTime = null;
|
||||
scheduleEndTime = null;
|
||||
scheduleDaysJson = null;
|
||||
} else if (!scheduleDays.length) {
|
||||
scheduleMode = 'always';
|
||||
scheduleStartTime = null;
|
||||
scheduleEndTime = null;
|
||||
scheduleDaysJson = null;
|
||||
} else {
|
||||
if (scheduleEndTime < scheduleStartTime) {
|
||||
return res.status(400).send('End time must be after start time.');
|
||||
}
|
||||
scheduleDaysJson = JSON.stringify(Array.from(new Set(scheduleDays)).sort());
|
||||
}
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE c_playlist_slides SET schedule_mode = ?, schedule_start_datetime = ?, schedule_end_datetime = ?, schedule_start_time = ?, schedule_end_time = ?, schedule_days_json = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[scheduleMode, scheduleStartDatetime, scheduleEndDatetime, scheduleStartTime, scheduleEndTime, scheduleDaysJson, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/screens/new', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const data = await common.fetchScreenEditData(pool);
|
||||
|
||||
@@ -0,0 +1,642 @@
|
||||
// Playlist admin routes and playlist-slide management.
|
||||
|
||||
module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const fetchOrderedPlaylistSlides = deps.fetchOrderedPlaylistSlides;
|
||||
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
|
||||
const fetchPlaylistCanvasId = deps.fetchPlaylistCanvasId;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||
const getPlaylistDeleteBlockMessage = deps.getPlaylistDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
function readRawFieldArray(reqBody, keys) {
|
||||
const searchKeys = Array.isArray(keys) ? keys : [keys];
|
||||
for (let index = 0; index < searchKeys.length; index += 1) {
|
||||
const key = searchKeys[index];
|
||||
if (!reqBody || !Object.prototype.hasOwnProperty.call(reqBody, key)) {
|
||||
continue;
|
||||
}
|
||||
const value = reqBody[key];
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(function (item) {
|
||||
return item === undefined || item === null ? '' : String(item);
|
||||
});
|
||||
}
|
||||
return [value === undefined || value === null ? '' : String(value)];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
function normalizeScheduleRuleValuesFromBody(reqBody, index) {
|
||||
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
|
||||
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
|
||||
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
|
||||
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
|
||||
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
|
||||
|
||||
const startDatetime = String(startDatetimeValues[index] || '').trim() || null;
|
||||
const endDatetime = String(endDatetimeValues[index] || '').trim() || null;
|
||||
const startTime = String(startTimeValues[index] || '').trim() || null;
|
||||
const endTime = String(endTimeValues[index] || '').trim() || null;
|
||||
const daysCsv = String(daysCsvValues[index] || '').trim();
|
||||
const days = daysCsv
|
||||
? Array.from(new Set(daysCsv.split(',').map(function (value) { return Number(value); }).filter(function (day) {
|
||||
return Number.isInteger(day) && day >= 0 && day <= 6;
|
||||
}))).sort(function (left, right) { return left - right; })
|
||||
: [];
|
||||
|
||||
return {
|
||||
start_datetime: startDatetime,
|
||||
end_datetime: endDatetime,
|
||||
start_time: startTime,
|
||||
end_time: endTime,
|
||||
schedule_days_json: days.length ? JSON.stringify(days) : null
|
||||
};
|
||||
}
|
||||
|
||||
function buildScheduleRulesFromRequestBody(reqBody) {
|
||||
const rowKeys = readRawFieldArray(reqBody, ['schedule_rule_row_key[]', 'schedule_rule_row_key']);
|
||||
const positions = readRawFieldArray(reqBody, ['schedule_rule_position[]', 'schedule_rule_position']);
|
||||
const startDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_datetime[]', 'schedule_rule_start_datetime']);
|
||||
const endDatetimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_datetime[]', 'schedule_rule_end_datetime']);
|
||||
const startTimeValues = readRawFieldArray(reqBody, ['schedule_rule_start_time[]', 'schedule_rule_start_time']);
|
||||
const endTimeValues = readRawFieldArray(reqBody, ['schedule_rule_end_time[]', 'schedule_rule_end_time']);
|
||||
const daysCsvValues = readRawFieldArray(reqBody, ['schedule_rule_days_csv[]', 'schedule_rule_days_csv']);
|
||||
|
||||
const lengths = [rowKeys.length, positions.length, startDatetimeValues.length, endDatetimeValues.length, startTimeValues.length, endTimeValues.length, daysCsvValues.length]
|
||||
.filter(function (value) { return value > 0; });
|
||||
if (lengths.length && lengths.some(function (length) { return length !== lengths[0]; })) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return rowKeys.map(function (rowKey, index) {
|
||||
const rule = normalizeScheduleRuleValuesFromBody(reqBody, index);
|
||||
if (!rule.start_datetime && !rule.end_datetime && !rule.start_time && !rule.end_time && !rule.schedule_days_json) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
rowKey: String(rowKey || '').trim(),
|
||||
position: Number(positions[index] || index) || 0,
|
||||
startDatetime: rule.start_datetime,
|
||||
endDatetime: rule.end_datetime,
|
||||
startTime: rule.start_time,
|
||||
endTime: rule.end_time,
|
||||
daysJson: rule.schedule_days_json
|
||||
};
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
async function replacePlaylistSlideScheduleRules(connection, playlistSlideId, scheduleRules, actorId) {
|
||||
await connection.query('DELETE FROM c_playlist_slide_schedule_rules WHERE playlist_slide_id = ?', [playlistSlideId]);
|
||||
for (let index = 0; index < scheduleRules.length; index += 1) {
|
||||
const rule = scheduleRules[index];
|
||||
await connection.query(
|
||||
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[playlistSlideId, rule.position || index, rule.startDatetime, rule.endDatetime, rule.startTime, rule.endTime, rule.daysJson, actorId, actorId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function createValidationError(message) {
|
||||
const error = new Error(message);
|
||||
error.statusCode = 400;
|
||||
return error;
|
||||
}
|
||||
|
||||
async function savePlaylistItems(connection, playlist, reqBody, actorId, options) {
|
||||
const shouldUpdatePlaylist = !options || options.updatePlaylist !== false;
|
||||
const name = String(reqBody.name || '').trim();
|
||||
const fadeBetweenSlides = reqBody.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = reqBody.skip_unavailable_rtmp ? 1 : 0;
|
||||
const canvasSizeId = Number(reqBody.canvas_size_id);
|
||||
let requestedCanvasId = null;
|
||||
|
||||
if (!name) {
|
||||
throw createValidationError('Playlist name is required.');
|
||||
}
|
||||
|
||||
if (shouldUpdatePlaylist && reqBody.canvas_size_id !== undefined && reqBody.canvas_size_id !== null && String(reqBody.canvas_size_id).trim() !== '') {
|
||||
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
|
||||
throw createValidationError('Canvas size is invalid.');
|
||||
}
|
||||
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
|
||||
if (!canvasSize) {
|
||||
throw createValidationError('Canvas size not found.');
|
||||
}
|
||||
requestedCanvasId = Number(canvasSize.id);
|
||||
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
|
||||
throw new Error('Canvas size cannot be changed after the playlist is created.');
|
||||
}
|
||||
}
|
||||
|
||||
const affectedScreens = await fetchScreensByPlaylistId(connection, playlist.id);
|
||||
|
||||
const slideIds = readArrayField(reqBody, ['slide_id[]', 'slide_id']);
|
||||
const rowKeys = readRawFieldArray(reqBody, ['row_key[]', 'row_key']);
|
||||
const durations = readArrayField(reqBody, ['duration_seconds[]', 'duration_seconds']);
|
||||
const useVideoDurations = readArrayField(reqBody, ['use_video_duration[]', 'use_video_duration']);
|
||||
const disableAudios = readArrayField(reqBody, ['disable_audio[]', 'disable_audio']);
|
||||
const scheduleRules = buildScheduleRulesFromRequestBody(reqBody);
|
||||
|
||||
if (durations.length && durations.length !== slideIds.length) {
|
||||
throw createValidationError('Playlist slide data is invalid.');
|
||||
}
|
||||
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
|
||||
throw createValidationError('Playlist slide data is invalid.');
|
||||
}
|
||||
if (disableAudios.length && disableAudios.length !== slideIds.length) {
|
||||
throw createValidationError('Playlist slide data is invalid.');
|
||||
}
|
||||
if (rowKeys.length && rowKeys.length !== slideIds.length) {
|
||||
throw createValidationError('Playlist slide data is invalid.');
|
||||
}
|
||||
|
||||
const scheduleRulesByRowKey = new Map();
|
||||
if (scheduleRules === null) {
|
||||
throw createValidationError('Playlist schedule data is invalid.');
|
||||
}
|
||||
scheduleRules.forEach(function (rule) {
|
||||
const rowKey = String(rule.rowKey || '').trim();
|
||||
if (!scheduleRulesByRowKey.has(rowKey)) {
|
||||
scheduleRulesByRowKey.set(rowKey, []);
|
||||
}
|
||||
scheduleRulesByRowKey.get(rowKey).push(rule);
|
||||
});
|
||||
|
||||
const normalizedSlides = [];
|
||||
const seenSlideIds = new Set();
|
||||
for (let i = 0; i < slideIds.length; i += 1) {
|
||||
const slideId = Number(slideIds[i]);
|
||||
if (!Number.isInteger(slideId) || slideId <= 0) {
|
||||
throw createValidationError('Invalid slide selection.');
|
||||
}
|
||||
if (seenSlideIds.has(slideId)) {
|
||||
throw createValidationError('A slide can only be added to a playlist once.');
|
||||
}
|
||||
seenSlideIds.add(slideId);
|
||||
|
||||
const durationRaw = Number(durations[i]);
|
||||
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
|
||||
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
|
||||
const disableAudio = String(disableAudios[i] || '') !== '0' ? 1 : 0;
|
||||
const rowKey = String(rowKeys[i] || '').trim();
|
||||
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
|
||||
for (const rule of playlistScheduleRules) {
|
||||
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
|
||||
throw createValidationError('End datetime must be after start datetime.');
|
||||
}
|
||||
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
|
||||
throw createValidationError('End time must be after start time.');
|
||||
}
|
||||
}
|
||||
|
||||
normalizedSlides.push({
|
||||
slideId,
|
||||
rowKey,
|
||||
position: i,
|
||||
durationSeconds,
|
||||
useVideoDuration,
|
||||
disableAudio,
|
||||
scheduleRules: playlistScheduleRules
|
||||
});
|
||||
}
|
||||
|
||||
let selectedCanvasId = null;
|
||||
if (normalizedSlides.length) {
|
||||
const [slides] = await connection.query(
|
||||
`SELECT sl.id, st.canvas_size_id AS canvas_size_id
|
||||
FROM c_slides sl
|
||||
LEFT JOIN c_templates st ON st.id = sl.template_id
|
||||
WHERE sl.id IN (?)`,
|
||||
[normalizedSlides.map(function (item) { return item.slideId; })]
|
||||
);
|
||||
if (slides.length !== normalizedSlides.length) {
|
||||
throw createValidationError('One or more selected slides no longer exist.');
|
||||
}
|
||||
const signatures = Array.from(new Set(
|
||||
slides
|
||||
.map(function (slide) { return Number(slide.canvas_size_id); })
|
||||
.filter(function (value) { return Number.isInteger(value) && value > 0; })
|
||||
));
|
||||
selectedCanvasId = signatures.length === 1 ? signatures[0] : null;
|
||||
if (signatures.length > 1) {
|
||||
throw createValidationError('All playlist slides must share the same canvas size.');
|
||||
}
|
||||
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && selectedCanvasId && Number(playlist.canvas_id) !== selectedCanvasId) {
|
||||
throw createValidationError('All playlist slides must match the playlist canvas size.');
|
||||
}
|
||||
}
|
||||
|
||||
const nextCanvasId = Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0
|
||||
? Number(playlist.canvas_id)
|
||||
: requestedCanvasId || selectedCanvasId || null;
|
||||
|
||||
if (shouldUpdatePlaylist) {
|
||||
await connection.query('UPDATE c_playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, canvas_id = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, nextCanvasId, actorId, playlist.id]);
|
||||
}
|
||||
|
||||
await connection.query('DELETE FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
const [insertResult] = await connection.query(
|
||||
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, actorId, actorId]
|
||||
);
|
||||
const insertedPlaylistSlideId = Number(insertResult.insertId);
|
||||
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
|
||||
for (let ruleIndex = 0; ruleIndex < itemRules.length; ruleIndex += 1) {
|
||||
const rule = itemRules[ruleIndex];
|
||||
await connection.query(
|
||||
'INSERT INTO c_playlist_slide_schedule_rules (playlist_slide_id, position, start_datetime, end_datetime, start_time, end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[insertedPlaylistSlideId, rule.position || ruleIndex, rule.startDatetime || null, rule.endDatetime || null, rule.startTime || null, rule.endTime || null, rule.daysJson || null, actorId, actorId]
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
affectedScreens: affectedScreens,
|
||||
nextCanvasId: nextCanvasId,
|
||||
selectedCanvasId: selectedCanvasId
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/playlists', requirePermission('playlists.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
const canvasSizeId = Number(req.body.canvas_size_id);
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
|
||||
return res.status(400).send('Canvas size is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, canvasSizeId);
|
||||
if (!canvasSize) {
|
||||
return res.status(400).send('Canvas size not found.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('INSERT INTO c_playlists (name, fade_between_slides, skip_unavailable_rtmp, canvas_id, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, canvasSize.id, actorId, actorId]);
|
||||
const playlist = {
|
||||
id: Number(result.insertId),
|
||||
canvas_id: Number(canvasSize.id)
|
||||
};
|
||||
await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: false });
|
||||
await connection.commit();
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist created.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
if (error && error.statusCode === 400) {
|
||||
return res.status(400).send(error.message || 'Playlist data is invalid.');
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||
const canvasSizeId = Number(req.body.canvas_size_id);
|
||||
const actorId = getAuditUserId(req);
|
||||
let requestedCanvasId = null;
|
||||
if (!name) {
|
||||
return res.status(400).send('Playlist name is required.');
|
||||
}
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_playlists', name, playlist.id)) {
|
||||
return res.status(400).send('A playlist with that name already exists.');
|
||||
}
|
||||
if (req.body.canvas_size_id !== undefined && req.body.canvas_size_id !== null && String(req.body.canvas_size_id).trim() !== '') {
|
||||
if (!Number.isInteger(canvasSizeId) || canvasSizeId <= 0) {
|
||||
return res.status(400).send('Canvas size is invalid.');
|
||||
}
|
||||
const canvasSize = await common.fetchCanvasSizeById(connection, canvasSizeId);
|
||||
if (!canvasSize) {
|
||||
return res.status(400).send('Canvas size not found.');
|
||||
}
|
||||
requestedCanvasId = Number(canvasSize.id);
|
||||
if (Number.isInteger(Number(playlist.canvas_id)) && Number(playlist.canvas_id) > 0 && Number(playlist.canvas_id) !== requestedCanvasId) {
|
||||
return res.status(400).send('Canvas size cannot be changed after the playlist is created.');
|
||||
}
|
||||
}
|
||||
|
||||
const saveResult = await savePlaylistItems(connection, playlist, req.body, actorId, { updatePlaylist: true });
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(saveResult.affectedScreens, 'refresh');
|
||||
await broadcastDashboardState();
|
||||
redirectAfterSave(req, res, '/playlists?edit=' + playlist.id, {
|
||||
closeUrl: '/playlists',
|
||||
newUrl: '/playlists/new',
|
||||
message: 'Playlist updated.'
|
||||
});
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
if (error && error.statusCode === 400) {
|
||||
return res.status(400).send(error.message || 'Playlist data is invalid.');
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/delete', requirePermission('playlists.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const blockMessage = await getPlaylistDeleteBlockMessage(pool, playlist);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/playlists?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
await pool.query('DELETE FROM c_playlists WHERE id = ?', [playlist.id]);
|
||||
res.redirect('/playlists?message=' + encodeURIComponent('Playlist deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const slideId = Number(req.body.slide_id);
|
||||
if (!slideId) {
|
||||
return res.status(400).send('Slide is required.');
|
||||
}
|
||||
const playlistCanvasId = await fetchPlaylistCanvasId(pool, playlist.id);
|
||||
if (playlistCanvasId === 'mismatch') {
|
||||
return res.status(400).send('This playlist already contains slides with different canvas sizes.');
|
||||
}
|
||||
const slide = await common.fetchSlideById(pool, slideId);
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
const slideCanvasId = Number(slide.canvas_size_id);
|
||||
if (playlistCanvasId && slideCanvasId !== playlistCanvasId) {
|
||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
||||
}
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM c_playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, 1, actorId, actorId]);
|
||||
if (!Number.isInteger(Number(playlist.canvas_id)) || Number(playlist.canvas_id) <= 0) {
|
||||
await pool.query('UPDATE c_playlists SET canvas_id = ?, modified_by = ? WHERE id = ?', [slideCanvasId, actorId, playlist.id]);
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const [currentRows] = await pool.query('SELECT disable_audio FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [playlistSlideId, playlist.id]);
|
||||
if (!currentRows.length) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
const disableAudio = req.body.disable_audio === undefined ? (Number(currentRows[0].disable_audio) ? 1 : 0) : (String(req.body.disable_audio || '') === '0' ? 0 : 1);
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE c_playlist_slides SET duration_seconds = ?, disable_audio = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[durationSeconds, disableAudio, actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide config updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/move', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(connection, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const direction = String(req.body.direction || '').toLowerCase();
|
||||
if (direction !== 'up' && direction !== 'down') {
|
||||
return res.status(400).send('Invalid move direction.');
|
||||
}
|
||||
|
||||
await connection.beginTransaction();
|
||||
const orderedSlides = await fetchOrderedPlaylistSlides(connection, playlist.id);
|
||||
const currentIndex = orderedSlides.findIndex(function (item) {
|
||||
return Number(item.id) === playlistSlideId;
|
||||
});
|
||||
if (currentIndex === -1) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= orderedSlides.length) {
|
||||
await connection.rollback();
|
||||
return res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide is already at the ' + (direction === 'up' ? 'top' : 'bottom') + '.'));
|
||||
}
|
||||
|
||||
const currentSlide = orderedSlides[currentIndex];
|
||||
const swapSlide = orderedSlides[swapIndex];
|
||||
const actorId = getAuditUserId(req);
|
||||
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [swapSlide.position, actorId, currentSlide.id, playlist.id]);
|
||||
await connection.query('UPDATE c_playlist_slides SET position = ?, modified_by = ? WHERE id = ? AND playlist_id = ?', [currentSlide.position, actorId, swapSlide.id, playlist.id]);
|
||||
await connection.commit();
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(connection, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide order updated.'));
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/new/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
|
||||
return String(key || '').indexOf('schedule_rule_') === 0;
|
||||
});
|
||||
const scheduleRules = hasQueryScheduleRules
|
||||
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
|
||||
let days = [];
|
||||
try {
|
||||
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
|
||||
} catch (_error) {
|
||||
days = [];
|
||||
}
|
||||
|
||||
return {
|
||||
start_datetime: rule.startDatetime,
|
||||
end_datetime: rule.endDatetime,
|
||||
start_time: rule.startTime,
|
||||
end_time: rule.endTime,
|
||||
days: Array.isArray(days) ? days : []
|
||||
};
|
||||
})
|
||||
: [];
|
||||
|
||||
return res.send(pages.renderPlaylistSlideConfigPage({ id: null }, {
|
||||
id: 0,
|
||||
scheduleRules: scheduleRules
|
||||
}, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlistId = Number(req.params.id);
|
||||
if (!Number.isInteger(playlistId) || playlistId <= 0) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
|
||||
const playlist = await common.fetchPlaylistById(pool, playlistId);
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const data = await common.fetchAdminData(pool);
|
||||
let playlistSlide = (data.playlistSlides || []).find(function (item) {
|
||||
return item.id === Number(req.params.playlistSlideId) && item.playlist_id === playlist.id;
|
||||
});
|
||||
if (!playlistSlide && Number(req.params.playlistSlideId) !== 0) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
const hasQueryScheduleRules = Object.keys(req.query || {}).some(function (key) {
|
||||
return String(key || '').indexOf('schedule_rule_') === 0;
|
||||
});
|
||||
const scheduleRules = hasQueryScheduleRules
|
||||
? (buildScheduleRulesFromRequestBody(req.query) || []).map(function (rule) {
|
||||
let days = [];
|
||||
try {
|
||||
days = rule && rule.daysJson ? JSON.parse(rule.daysJson) : [];
|
||||
} catch (_error) {
|
||||
days = [];
|
||||
}
|
||||
|
||||
return {
|
||||
start_datetime: rule.startDatetime,
|
||||
end_datetime: rule.endDatetime,
|
||||
start_time: rule.startTime,
|
||||
end_time: rule.endTime,
|
||||
days: Array.isArray(days) ? days : []
|
||||
};
|
||||
})
|
||||
: (Array.isArray(playlistSlide && playlistSlide.scheduleRules) ? playlistSlide.scheduleRules : []);
|
||||
|
||||
playlistSlide = Object.assign({}, playlistSlide || {}, {
|
||||
id: playlistSlide ? playlistSlide.id : 0,
|
||||
scheduleRules: scheduleRules
|
||||
});
|
||||
return res.send(pages.renderPlaylistSlideConfigPage(playlist, playlistSlide, req.query.message ? String(req.query.message) : '', req.query.row_key ? String(req.query.row_key) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/config', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const actorId = getAuditUserId(req);
|
||||
const scheduleRules = buildScheduleRulesFromRequestBody(req.body);
|
||||
if (scheduleRules === null) {
|
||||
return res.status(400).send('Schedule rules are invalid.');
|
||||
}
|
||||
for (const rule of scheduleRules) {
|
||||
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
|
||||
return res.status(400).send('End datetime must be after start datetime.');
|
||||
}
|
||||
if (rule.start_time && rule.end_time && rule.end_time < rule.start_time) {
|
||||
return res.status(400).send('End time must be after start time.');
|
||||
}
|
||||
}
|
||||
|
||||
const [result] = await pool.query(
|
||||
'UPDATE c_playlist_slides SET modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
[actorId, playlistSlideId, playlist.id]
|
||||
);
|
||||
if (!result.affectedRows) {
|
||||
return res.status(404).send('Playlist slide not found');
|
||||
}
|
||||
await replacePlaylistSlideScheduleRules(pool, playlistSlideId, scheduleRules, actorId);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide timings updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/playlists/:id/slides/:playlistSlideId/delete', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
if (!playlist) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
await pool.query('DELETE FROM c_playlist_slides WHERE id = ? AND playlist_id = ?', [Number(req.params.playlistSlideId), playlist.id]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide removed.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -129,14 +129,19 @@
|
||||
return groups;
|
||||
}
|
||||
|
||||
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys) {
|
||||
async function buildRoleCreateViewModel(formValues, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId) {
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, {
|
||||
excludeUserId: currentUserId
|
||||
});
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys))
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(data.users, selectedUserIds),
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')
|
||||
};
|
||||
}
|
||||
|
||||
@@ -177,11 +182,16 @@
|
||||
});
|
||||
|
||||
app.get('/rbac/new', requirePermission('rbac.create'), function (req, res, next) {
|
||||
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 currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
buildRoleCreateViewModel({
|
||||
name: String(req.query.name || '').trim(),
|
||||
description: String(req.query.description || '').trim()
|
||||
}, []).then(function (viewModel) {
|
||||
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, 'primary'));
|
||||
}, [], [], page, search, sort, direction, currentUserId).then(function (viewModel) {
|
||||
res.send(pages.renderRbacAddPage(req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
|
||||
}).catch(function (error) {
|
||||
next(error);
|
||||
});
|
||||
@@ -191,6 +201,10 @@
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
const normalizedUserIds = normalizeSelectedIds(selectedUserIds);
|
||||
const selectedPermissionKeys = normalizePermissionKeys(Array.isArray(req.body['permission_keys[]'])
|
||||
? req.body['permission_keys[]']
|
||||
: req.body.permission_keys
|
||||
@@ -199,18 +213,33 @@
|
||||
const validPermissionKeys = new Set(permissions.map(function (permission) {
|
||||
return String(permission.key || '').trim();
|
||||
}));
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys);
|
||||
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 currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const createViewModel = await buildRoleCreateViewModel({ name: name, description: description }, selectedPermissionKeys, selectedUserIds, page, search, sort, direction, currentUserId);
|
||||
if (!name) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
return res.status(400).send(pages.renderRbacAddPage('Role name is required.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'a_roles', name)) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
return res.status(400).send(pages.renderRbacAddPage('A role with that name already exists.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
|
||||
}
|
||||
|
||||
if (selectedPermissionKeys.some(function (permissionKey) {
|
||||
return !validPermissionKeys.has(permissionKey);
|
||||
})) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, 'warning'));
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected permissions are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
|
||||
}
|
||||
|
||||
const availableUsers = await rbacData.fetchUsersWithRoles(pool);
|
||||
const availableUserIds = new Set(availableUsers.map(function (user) {
|
||||
return Number(user.id);
|
||||
}));
|
||||
if (normalizedUserIds.some(function (userId) {
|
||||
return !availableUserIds.has(userId);
|
||||
})) {
|
||||
return res.status(400).send(pages.renderRbacAddPage('One or more selected users are invalid.', req.currentUser, createViewModel.formValues, createViewModel.permissionGroups, createViewModel.users, createViewModel.pagination, 'warning'));
|
||||
}
|
||||
|
||||
const roleKey = await createUniqueRoleKey(name);
|
||||
@@ -227,6 +256,7 @@
|
||||
if (selectedPermissionKeys.length) {
|
||||
await rbacData.syncRolePermissions(connection, insertedRoleId, selectedPermissionKeys);
|
||||
}
|
||||
await rbacData.syncRoleUsers(connection, insertedRoleId, normalizedUserIds);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
|
||||
@@ -123,7 +123,7 @@
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || '').trim();
|
||||
const username = String(req.body.username || user.username || '').trim();
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
@@ -171,7 +171,7 @@
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
res.redirect('/users?message=' + encodeURIComponent('User created.'));
|
||||
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
@@ -213,46 +213,93 @@
|
||||
});
|
||||
|
||||
app.post('/users/:id/username', requirePermission('users.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const shouldUpdatePassword = Boolean(password || confirmPassword);
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
if (!name) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Name is required.'));
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own username or password.'));
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to update your own login details.'));
|
||||
}
|
||||
|
||||
const [userRows] = await pool.query('SELECT id, username FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
if (!userRows.length) {
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const username = String(req.body.username || userRows[0].username || '').trim();
|
||||
if (!username) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('Username is required.'));
|
||||
const username = String(req.body.username || user.username || '').trim();
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
async function renderValidationError(message) {
|
||||
const roles = await fetchRoleOptions();
|
||||
return res.status(400).send(pages.renderUsersEditPage(Object.assign({}, user, {
|
||||
name: name || user.name,
|
||||
username: username || user.username,
|
||||
roleIds: selectedRoleIds,
|
||||
inUse: !canDelete
|
||||
}), message, req.currentUser, mapRolesForForm(roles, selectedRoleIds)));
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
return renderValidationError('Name is required.');
|
||||
}
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (shouldUpdatePassword && (!password || password.length < 8)) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
}
|
||||
if (shouldUpdatePassword && password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
}
|
||||
if (!roleCheck.ok) {
|
||||
return renderValidationError(roleCheck.message);
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'a_users', name, userId)) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That name already exists.'));
|
||||
return renderValidationError('That name already exists.');
|
||||
}
|
||||
|
||||
const [existingRows] = await pool.query('SELECT id FROM a_users WHERE username = ? AND id <> ? LIMIT 1', [username, userId]);
|
||||
if (existingRows.length) {
|
||||
return res.redirect('/users/' + userId + '/edit?message=' + encodeURIComponent('That username already exists.'));
|
||||
return renderValidationError('That username already exists.');
|
||||
}
|
||||
|
||||
const [result] = await pool.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query('UPDATE a_users SET name = ?, username = ?, modified_by = ? WHERE id = ?', [name, username, getAuditUserId(req), userId]);
|
||||
if (!result.affectedRows) {
|
||||
await connection.rollback();
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
await rbacData.syncUserRoles(connection, userId, roleCheck.roleIds);
|
||||
if (shouldUpdatePassword) {
|
||||
const passwordRecord = hashPassword(password);
|
||||
await connection.query(
|
||||
'UPDATE a_users SET password_hash = ?, password_salt = ?, password_iterations = ?, modified_by = ? WHERE id = ?',
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
}
|
||||
await connection.commit();
|
||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
await connection.rollback();
|
||||
} catch (_rollbackError) {
|
||||
// Ignore rollback failures and surface the original error.
|
||||
}
|
||||
next(error);
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ module.exports = function renderLoginPage(message) {
|
||||
title: 'Sign in',
|
||||
authShell: true,
|
||||
bodyClass: 'login-page-body',
|
||||
message: message || ''
|
||||
message: message || '',
|
||||
messageVariant: 'warning'
|
||||
});
|
||||
};
|
||||
@@ -1,41 +1,8 @@
|
||||
// API source add page renderer and form defaults.
|
||||
// API source add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildApiSourceFormViewModel } = require('./form-view-model');
|
||||
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
authMethod: 'none',
|
||||
authUsername: '',
|
||||
authPassword: '',
|
||||
authBearerToken: '',
|
||||
authHeaderName: 'X-API-Key',
|
||||
authHeaderValue: '',
|
||||
itemsPath: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderApiSourceFormPage(apiSource, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/api-sources/edit' : 'data-sources/api-sources/add', {
|
||||
title: isEdit ? 'Edit API source' : 'Add API source',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewApiSource.lastPullError || ''
|
||||
});
|
||||
module.exports = function renderApiSourceAddPage(apiSource, message, currentUser, options) {
|
||||
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(apiSource, message, currentUser, options, false));
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// API source edit page renderer that reuses the add form.
|
||||
// API source edit page renderer.
|
||||
|
||||
const renderApiSourceFormPage = require('./add');
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildApiSourceFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderApiSourceEditPage(apiSource, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
@@ -8,7 +9,7 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
|
||||
lastPullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderApiSourceFormPage(Object.assign({}, apiSource, {
|
||||
return renderView('data-sources/api-sources/form', buildApiSourceFormViewModel(Object.assign({}, apiSource, {
|
||||
lastResponseJson: viewData.lastResponseJson,
|
||||
lastPullError: viewData.lastPullError,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
@@ -18,5 +19,5 @@ module.exports = function renderApiSourceEditPage(apiSource, data, message, curr
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || ''
|
||||
}), 'edit', message, currentUser);
|
||||
}), message, currentUser, viewData, true));
|
||||
};
|
||||
@@ -0,0 +1,50 @@
|
||||
// Shared API source form view-model builder.
|
||||
|
||||
function buildDefaultApiSource() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
apiUrl: '',
|
||||
authMethod: 'none',
|
||||
authUsername: '',
|
||||
authPassword: '',
|
||||
authBearerToken: '',
|
||||
authHeaderName: 'X-API-Key',
|
||||
authHeaderValue: '',
|
||||
itemsPath: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: ''
|
||||
};
|
||||
}
|
||||
|
||||
function buildApiSourceFormViewModel(apiSource, message, currentUser, options, isEdit) {
|
||||
const viewApiSource = Object.assign(buildDefaultApiSource(), apiSource || {});
|
||||
const viewOptions = options || {};
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit API source' : 'Add API source',
|
||||
active: 'api-sources',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
apiSource: viewApiSource,
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: !isEdit || Boolean(viewApiSource.inUse),
|
||||
formAction: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id : '/data-sources/api-sources',
|
||||
formAttrs: isEdit && viewApiSource.id ? 'data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/' + viewApiSource.id + '/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new"' : 'data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/api-sources/new"',
|
||||
cancelUrl: '/data-sources/api-sources',
|
||||
deleteUrl: isEdit && viewApiSource.id ? '/data-sources/api-sources/' + viewApiSource.id + '/delete' : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildApiSourceFormViewModel: buildApiSourceFormViewModel
|
||||
};
|
||||
@@ -0,0 +1,326 @@
|
||||
// API source route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderApiSourcesPage = require('./list');
|
||||
const renderApiSourceAddPage = require('./add');
|
||||
const renderApiSourceEditPage = require('./edit');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? '' : date.toISOString();
|
||||
}
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const apiSourceIds = new Set();
|
||||
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'source_id')) {
|
||||
const sourceId = Number(value.source_id);
|
||||
if (Number.isFinite(sourceId)) {
|
||||
apiSourceIds.add(sourceId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return apiSourceIds;
|
||||
}
|
||||
|
||||
async function isApiSourceInUse(pool, common, apiSourceId) {
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
return usageIds.has(Number(apiSourceId));
|
||||
}
|
||||
|
||||
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = await dataSourceTasks.getTaskStatusById(taskId);
|
||||
const expectedKey = 'api-source-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const dataSourceTasks = deps.dataSourceTasks;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/data-sources/api-sources', requirePermission('api-sources.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.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||
return Object.assign({}, apiSource, {
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : '',
|
||||
inUse: usageIds.has(Number(apiSource.id))
|
||||
});
|
||||
});
|
||||
|
||||
res.send(renderApiSourcesPage({
|
||||
apiSources: apiSources,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/new', requirePermission('api-sources.create'), function (req, res) {
|
||||
res.send(renderApiSourceAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/edit', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const inUse = (slides || []).some(function (row) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stack = [content];
|
||||
while (stack.length) {
|
||||
const value = stack.pop();
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(function (item) { stack.push(item); });
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'source_id') && Number(value.source_id) === Number(apiSource.id)) {
|
||||
return true;
|
||||
}
|
||||
Object.keys(value).forEach(function (key) {
|
||||
stack.push(value[key]);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
res.send(renderApiSourceEditPage(Object.assign({}, apiSource, {
|
||||
apiUrl: apiSource.api_url,
|
||||
authMethod: apiSource.auth_method || 'none',
|
||||
authUsername: apiSource.auth_username || '',
|
||||
authPassword: apiSource.auth_password || '',
|
||||
authBearerToken: apiSource.auth_bearer_token || '',
|
||||
authHeaderName: apiSource.auth_header_name || 'X-API-Key',
|
||||
authHeaderValue: apiSource.auth_header_value || '',
|
||||
itemsPath: apiSource.items_path || '',
|
||||
updateIntervalValue: apiSource.update_interval_value,
|
||||
updateIntervalUnit: apiSource.update_interval_unit || 'minutes',
|
||||
lastPulledAtValue: toIsoTimestamp(apiSource.last_pulled_at),
|
||||
lastPulledAtLabel: apiSource.last_pulled_at ? String(apiSource.last_pulled_at) : '',
|
||||
lastPullError: apiSource.last_pull_error || '',
|
||||
lastResponseStatus: apiSource.last_response_status,
|
||||
lastResponseContentType: apiSource.last_response_content_type,
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
inUse: inUse
|
||||
}), {
|
||||
lastResponseJson: apiSource.last_response_json || '',
|
||||
lastPullError: apiSource.last_pull_error || ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/state', requirePermission('api-sources.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/api-sources', requirePermission('api-sources.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildApiSourcePayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name)) {
|
||||
return res.redirect('/data-sources/api-sources/new?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_api_sources (name, api_url, auth_method, auth_username, auth_password, auth_bearer_token, auth_header_name, auth_header_value, items_path, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(result.insertId, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + result.insertId,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'API source created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/api-sources/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} 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/api-sources/:id', requirePermission('api-sources.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
const payload = common.buildApiSourcePayload(req, apiSource);
|
||||
if (await common.fetchDuplicateName(pool, 'i_api_sources', payload.name, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources/' + apiSource.id + '/edit?message=' + encodeURIComponent('An API source with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_api_sources SET name = ?, api_url = ?, auth_method = ?, auth_username = ?, auth_password = ?, auth_bearer_token = ?, auth_header_name = ?, auth_header_value = ?, items_path = ?, update_interval_value = ?, update_interval_unit = ?, last_pulled_at = ?, last_pull_error = ?, last_response_status = ?, last_response_content_type = ?, last_response_json = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.apiUrl, payload.authMethod, payload.authUsername || null, payload.authPassword || null, payload.authBearerToken || null, payload.authHeaderName || null, payload.authHeaderValue || null, payload.itemsPath || null, payload.updateIntervalValue, payload.updateIntervalUnit, null, null, null, null, null, actorId, apiSource.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('api-source', apiSource.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshApiSourceInBackground(apiSource.id, actorId);
|
||||
});
|
||||
const message = 'API source updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'api-source-refresh:' + apiSource.id,
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: apiSource.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
redirectAfterSave(req, res, '/data-sources/api-sources/' + apiSource.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
|
||||
closeUrl: '/data-sources/api-sources',
|
||||
newUrl: '/data-sources/api-sources/new',
|
||||
message: 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/api-sources/:id/delete', requirePermission('api-sources.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const apiSource = await common.fetchApiSourceById(pool, Number(req.params.id));
|
||||
if (!apiSource) {
|
||||
return res.status(404).send('API source not found');
|
||||
}
|
||||
|
||||
if (await isApiSourceInUse(pool, common, apiSource.id)) {
|
||||
return res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('This API source is still used by one or more slides.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_api_sources WHERE id = ?', [apiSource.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.removeRecurringRefresh('api-source', apiSource.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/api-sources?message=' + encodeURIComponent('API source deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,4 +0,0 @@
|
||||
// Data-source route entry point that re-exports the admin handlers.
|
||||
|
||||
module.exports = require('../admin/data-sources');
|
||||
|
||||
@@ -1,30 +1,8 @@
|
||||
// RSS feed add page renderer and form defaults.
|
||||
// RSS feed add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRssFeedFormViewModel } = require('./form-view-model');
|
||||
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
itemLimit: 1
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderRssFeedFormPage(rssFeed, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
|
||||
|
||||
return renderView(isEdit ? 'data-sources/rss-feeds/edit' : 'data-sources/rss-feeds/add', {
|
||||
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeed: viewRssFeed,
|
||||
inUse: Boolean(viewRssFeed.inUse),
|
||||
pulledItems: viewRssFeed.pulledItems || [],
|
||||
pullError: viewRssFeed.pullError || ''
|
||||
});
|
||||
module.exports = function renderRssFeedAddPage(rssFeed, message, currentUser, options) {
|
||||
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(rssFeed, message, currentUser, options, false));
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// RSS feed edit page renderer that reuses the add form.
|
||||
// RSS feed edit page renderer.
|
||||
|
||||
const renderRssFeedFormPage = require('./add');
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRssFeedFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentUser) {
|
||||
const viewData = Object.assign({
|
||||
@@ -8,8 +9,8 @@ module.exports = function renderRssFeedEditPage(rssFeed, data, message, currentU
|
||||
pullError: ''
|
||||
}, data || {});
|
||||
|
||||
return renderRssFeedFormPage(Object.assign({}, rssFeed, {
|
||||
return renderView('data-sources/rss-feeds/form', buildRssFeedFormViewModel(Object.assign({}, rssFeed, {
|
||||
pulledItems: viewData.pulledItems,
|
||||
pullError: viewData.pullError
|
||||
}), 'edit', message, currentUser);
|
||||
}), message, currentUser, viewData, true));
|
||||
};
|
||||
@@ -0,0 +1,39 @@
|
||||
// Shared RSS feed form view-model builder.
|
||||
|
||||
function buildDefaultRssFeed() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
feedUrl: '',
|
||||
updateIntervalValue: 60,
|
||||
updateIntervalUnit: 'minutes',
|
||||
itemLimit: 1
|
||||
};
|
||||
}
|
||||
|
||||
function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdit) {
|
||||
const viewRssFeed = Object.assign(buildDefaultRssFeed(), rssFeed || {});
|
||||
const viewOptions = options || {};
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit RSS feed' : 'Add RSS feed',
|
||||
active: 'rss-feeds',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
rssFeed: viewRssFeed,
|
||||
inUse: Boolean(viewRssFeed.inUse),
|
||||
pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [],
|
||||
pullError: viewOptions.pullError || viewRssFeed.pullError || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: !isEdit || Boolean(viewRssFeed.inUse),
|
||||
formAction: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id : '/data-sources/rss-feeds',
|
||||
formAttrs: isEdit && viewRssFeed.id ? 'data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/' + viewRssFeed.id + '/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new"' : 'data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-redirect="response-url" data-async-save-new-url="/data-sources/rss-feeds/new"',
|
||||
cancelUrl: '/data-sources/rss-feeds',
|
||||
deleteUrl: isEdit && viewRssFeed.id ? '/data-sources/rss-feeds/' + viewRssFeed.id + '/delete' : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildRssFeedFormViewModel: buildRssFeedFormViewModel
|
||||
};
|
||||
@@ -0,0 +1,296 @@
|
||||
// RSS feed route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderRssFeedsPage = require('./list');
|
||||
const renderRssFeedAddPage = require('./add');
|
||||
const renderRssFeedEditPage = require('./edit');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const rssFeedIds = new Set();
|
||||
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'feed_id')) {
|
||||
const feedId = Number(value.feed_id);
|
||||
if (Number.isFinite(feedId)) {
|
||||
rssFeedIds.add(feedId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return rssFeedIds;
|
||||
}
|
||||
|
||||
async function isRssFeedInUse(pool, common, rssFeedId) {
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
return usageIds.has(Number(rssFeedId));
|
||||
}
|
||||
|
||||
async function sendRefreshTaskState(req, res, dataSourceTasks, sourceId) {
|
||||
const taskId = Number(req.query.refresh_task_id);
|
||||
if (!Number.isFinite(taskId) || taskId <= 0) {
|
||||
return res.status(400).json({ error: 'Missing refresh task id.' });
|
||||
}
|
||||
|
||||
const task = await dataSourceTasks.getTaskStatusById(taskId);
|
||||
const expectedKey = 'rss-feed-refresh:' + Number(sourceId);
|
||||
if (!task || task.key !== expectedKey) {
|
||||
return res.status(404).json({ error: 'Refresh task not found.' });
|
||||
}
|
||||
|
||||
res.json(task);
|
||||
}
|
||||
|
||||
module.exports = function registerRssFeedRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const dataSourceTasks = deps.dataSourceTasks;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.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.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const rssFeeds = (data.rssFeeds || []).map(function (rssFeed) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
inUse: usageIds.has(Number(rssFeed.id))
|
||||
});
|
||||
});
|
||||
res.send(renderRssFeedsPage({
|
||||
rssFeeds: rssFeeds,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/new', requirePermission('rss-feeds.create'), function (req, res) {
|
||||
res.send(renderRssFeedAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/edit', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const inUse = (slides || []).some(function (row) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const stack = [content];
|
||||
while (stack.length) {
|
||||
const value = stack.pop();
|
||||
if (!value || typeof value !== 'object') {
|
||||
continue;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(function (item) { stack.push(item); });
|
||||
continue;
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'feed_id') && Number(value.feed_id) === Number(rssFeed.id)) {
|
||||
return true;
|
||||
}
|
||||
Object.keys(value).forEach(function (key) {
|
||||
stack.push(value[key]);
|
||||
});
|
||||
}
|
||||
return false;
|
||||
});
|
||||
|
||||
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||
: [];
|
||||
|
||||
res.send(renderRssFeedEditPage(Object.assign({}, rssFeed, {
|
||||
feedUrl: rssFeed.feed_url,
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit,
|
||||
inUse: inUse
|
||||
}), {
|
||||
pulledItems: pulledItems,
|
||||
pullError: ''
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/state', requirePermission('rss-feeds.update'), function (req, res, next) {
|
||||
sendRefreshTaskState(req, res, dataSourceTasks, Number(req.params.id)).catch(next);
|
||||
});
|
||||
|
||||
app.post('/data-sources/rss-feeds', requirePermission('rss-feeds.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildRssFeedPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name)) {
|
||||
return res.redirect('/data-sources/rss-feeds/new?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
const [result] = await connection.query(
|
||||
'INSERT INTO i_rss_feeds (name, feed_url, update_interval_value, update_interval_unit, item_limit, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, actorId]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', result.insertId, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(result.insertId, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + result.insertId,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: result.insertId,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
const message = 'RSS feed created. Refresh is running in the background.';
|
||||
res.redirect('/data-sources/rss-feeds/' + result.insertId + '/edit?message=' + encodeURIComponent(message) + '&refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''));
|
||||
} 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/rss-feeds/:id', requirePermission('rss-feeds.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
const payload = common.buildRssFeedPayload(req, rssFeed);
|
||||
if (await common.fetchDuplicateName(pool, 'i_rss_feeds', payload.name, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds/' + rssFeed.id + '/edit?message=' + encodeURIComponent('An RSS feed with that name already exists.'));
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
await connection.beginTransaction();
|
||||
await connection.query(
|
||||
'UPDATE i_rss_feeds SET name = ?, feed_url = ?, update_interval_value = ?, update_interval_unit = ?, item_limit = ?, modified_by = ? WHERE id = ?',
|
||||
[payload.name, payload.feedUrl, payload.updateIntervalValue, payload.updateIntervalUnit, payload.itemLimit, actorId, rssFeed.id]
|
||||
);
|
||||
await connection.commit();
|
||||
dataSourceTasks.registerRecurringRefresh('rss-feed', rssFeed.id, payload.name, payload.updateIntervalValue, payload.updateIntervalUnit, function () {
|
||||
return dataSourceTasks.refreshRssFeedInBackground(rssFeed.id, payload.feedUrl, payload.itemLimit, actorId);
|
||||
});
|
||||
const message = 'RSS feed updated. Refresh is running in the background.';
|
||||
const refreshTask = await backgroundTaskQueue.enqueueTask({
|
||||
key: 'rss-feed-refresh:' + rssFeed.id,
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
payload: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name,
|
||||
actorId: actorId
|
||||
},
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: rssFeed.id,
|
||||
sourceName: payload.name
|
||||
}
|
||||
});
|
||||
redirectAfterSave(req, res, '/data-sources/rss-feeds/' + rssFeed.id + '/edit?refresh_task_id=' + encodeURIComponent(refreshTask && refreshTask.id ? refreshTask.id : ''), {
|
||||
closeUrl: '/data-sources/rss-feeds',
|
||||
newUrl: '/data-sources/rss-feeds/new',
|
||||
message: 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/rss-feeds/:id/delete', requirePermission('rss-feeds.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, Number(req.params.id));
|
||||
if (!rssFeed) {
|
||||
return res.status(404).send('RSS feed not found');
|
||||
}
|
||||
|
||||
if (await isRssFeedInUse(pool, common, rssFeed.id)) {
|
||||
return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by one or more slides.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_rss_feeds WHERE id = ?', [rssFeed.id]);
|
||||
await connection.commit();
|
||||
dataSourceTasks.removeRecurringRefresh('rss-feed', rssFeed.id);
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('RSS feed deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,54 +0,0 @@
|
||||
// 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)
|
||||
});
|
||||
};
|
||||
@@ -1,32 +0,0 @@
|
||||
// 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
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Timetable group add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false));
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Timetable group edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildTimetableGroupFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderTimetableGroupEditPage(timetableGroup, timetableEntries, message, currentUser) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, true));
|
||||
};
|
||||
@@ -0,0 +1,61 @@
|
||||
// Shared timetable group form view-model builder.
|
||||
|
||||
function buildDefaultTimetableGroup() {
|
||||
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}`;
|
||||
}
|
||||
|
||||
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit) {
|
||||
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
|
||||
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
|
||||
? timetableEntries.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 {
|
||||
title: isEdit ? 'Edit timetable group' : 'Add timetable group',
|
||||
active: 'timetables',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
isEdit: Boolean(isEdit),
|
||||
timetableGroup: viewTimetableGroup,
|
||||
timetableEntries: viewTimetableEntries,
|
||||
inUse: Boolean(viewTimetableGroup.inUse),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: !isEdit || Boolean(viewTimetableGroup.inUse),
|
||||
formAction: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id : '/data-sources/timetables',
|
||||
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/data-sources/timetables"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/data-sources/timetables/new"',
|
||||
cancelUrl: '/data-sources/timetables',
|
||||
deleteUrl: isEdit && viewTimetableGroup.id ? '/data-sources/timetables/' + viewTimetableGroup.id + '/delete' : '',
|
||||
assetVersion: Date.now().toString(36)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTimetableGroupFormViewModel: buildTimetableGroupFormViewModel
|
||||
};
|
||||
@@ -0,0 +1,32 @@
|
||||
// Timetable 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 renderTimetableGroupsPage(data, message, currentUser, formatDashboardDate) {
|
||||
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
|
||||
return Object.assign({}, timetableGroup, {
|
||||
nextStartLabel: formatNextStartLabel(timetableGroup.next_start_datetime, formatDashboardDate),
|
||||
nextStartValue: timetableGroup.next_start_datetime ? new Date(timetableGroup.next_start_datetime).toISOString() : ''
|
||||
});
|
||||
});
|
||||
|
||||
return renderView('data-sources/timetables/list', {
|
||||
title: 'Timetables',
|
||||
active: 'timetables',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
timetableGroups: timetableGroups,
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,319 @@
|
||||
// Timetable route registration.
|
||||
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderTimetableGroupsPage = require('./list');
|
||||
const renderTimetableGroupAddPage = require('./add');
|
||||
const renderTimetableGroupEditPage = require('./edit');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
const timetableGroupIds = new Set();
|
||||
|
||||
for (let index = 0; index < slides.length; index += 1) {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
continue;
|
||||
}
|
||||
|
||||
(function walk(value) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
value.forEach(walk);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
|
||||
const timetableGroupId = Number(value.schedule_group_id);
|
||||
if (Number.isFinite(timetableGroupId)) {
|
||||
timetableGroupIds.add(timetableGroupId);
|
||||
}
|
||||
}
|
||||
|
||||
Object.keys(value).forEach(function (key) {
|
||||
walk(value[key]);
|
||||
});
|
||||
})(content);
|
||||
}
|
||||
|
||||
return timetableGroupIds;
|
||||
}
|
||||
|
||||
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, parseDateTimeLocal) {
|
||||
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;
|
||||
}
|
||||
|
||||
module.exports = function registerTimetableRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
const pages = deps.pages;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
const parseDateTimeLocal = deps.parseDateTimeLocal;
|
||||
const formatDashboardDate = deps.formatDashboardDate || function (value) {
|
||||
return value ? String(value) : '';
|
||||
};
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
app.get('/data-sources/timetables', requirePermission('timetables.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.fetchTimetableGroupsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const timetableGroups = (data.timetableGroups || []).map(function (timetableGroup) {
|
||||
return Object.assign({}, timetableGroup, {
|
||||
inUse: usageIds.has(Number(timetableGroup.id))
|
||||
});
|
||||
});
|
||||
|
||||
res.send(renderTimetableGroupsPage({
|
||||
timetableGroups: timetableGroups,
|
||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'Timetables', 'Timetable groups')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/timetables/new', requirePermission('timetables.create'), function (req, res) {
|
||||
res.send(renderTimetableGroupAddPage(null, [], req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/data-sources/timetables/:id/edit', requirePermission('timetables.update'), async function (req, res, next) {
|
||||
try {
|
||||
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
|
||||
if (!timetableGroup) {
|
||||
return res.status(404).send('Timetable group not found');
|
||||
}
|
||||
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
const timetableEntries = typeof common.fetchTimetableEntriesByGroupId === 'function'
|
||||
? await common.fetchTimetableEntriesByGroupId(pool, timetableGroup.id)
|
||||
: [];
|
||||
|
||||
res.send(renderTimetableGroupEditPage(Object.assign({}, timetableGroup, {
|
||||
shortDescription: timetableGroup.short_description || '',
|
||||
inUse: usageIds.has(Number(timetableGroup.id))
|
||||
}), timetableEntries, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/data-sources/timetables', requirePermission('timetables.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const payload = common.buildTimetableGroupPayload(req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name)) {
|
||||
return res.redirect('/data-sources/timetables/new?message=' + encodeURIComponent('A timetable group with that name already exists.'));
|
||||
}
|
||||
|
||||
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
|
||||
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/timetables/' + result.insertId + '/edit', {
|
||||
closeUrl: '/data-sources/timetables',
|
||||
newUrl: '/data-sources/timetables/new',
|
||||
message: 'Timetable group created.'
|
||||
});
|
||||
} 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/timetables/:id', requirePermission('timetables.update'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
|
||||
if (!timetableGroup) {
|
||||
return res.status(404).send('Timetable group not found');
|
||||
}
|
||||
|
||||
const payload = common.buildTimetableGroupPayload(req, timetableGroup);
|
||||
if (await common.fetchDuplicateName(pool, 'i_schedule_groups', payload.name, timetableGroup.id)) {
|
||||
return res.redirect('/data-sources/timetables/' + timetableGroup.id + '/edit?message=' + encodeURIComponent('A timetable group with that name already exists.'));
|
||||
}
|
||||
|
||||
const entryRows = buildScheduleEntryRows(req, parseDateTimeLocal);
|
||||
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, timetableGroup.id]
|
||||
);
|
||||
await connection.query('DELETE FROM i_schedule_entries WHERE schedule_group_id = ?', [timetableGroup.id]);
|
||||
|
||||
if (entryRows.length) {
|
||||
const insertRows = entryRows.map(function (entry) {
|
||||
return [
|
||||
timetableGroup.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();
|
||||
redirectAfterSave(req, res, '/data-sources/timetables/' + timetableGroup.id + '/edit', {
|
||||
closeUrl: '/data-sources/timetables',
|
||||
newUrl: '/data-sources/timetables/new',
|
||||
message: 'Timetable group updated.'
|
||||
});
|
||||
} 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/timetables/:id/delete', requirePermission('timetables.delete'), async function (req, res, next) {
|
||||
try {
|
||||
const timetableGroup = await common.fetchTimetableGroupById(pool, Number(req.params.id));
|
||||
if (!timetableGroup) {
|
||||
return res.status(404).send('Timetable group not found');
|
||||
}
|
||||
|
||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||
if (usageIds.has(Number(timetableGroup.id))) {
|
||||
return res.redirect('/data-sources/timetables?message=' + encodeURIComponent('This timetable group is still used by one or more slides.'));
|
||||
}
|
||||
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await connection.query('DELETE FROM i_schedule_groups WHERE id = ?', [timetableGroup.id]);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/data-sources/timetables?message=' + encodeURIComponent('Timetable group deleted.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
// Error page route helpers and error copy selection.
|
||||
|
||||
const { renderView } = require('../view');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
function getErrorCopy(statusCode, message) {
|
||||
const normalizedStatusCode = Number(statusCode) || 500;
|
||||
@@ -9,7 +10,7 @@ function getErrorCopy(statusCode, message) {
|
||||
return {
|
||||
title: 'Access denied',
|
||||
errorTitle: 'Access denied',
|
||||
errorMessage: message || 'You do not have permission to access this area.',
|
||||
errorMessage: message || PERMISSION_DENIED_MESSAGE,
|
||||
backLabel: 'Back to dashboard'
|
||||
};
|
||||
}
|
||||
@@ -47,7 +48,7 @@ module.exports = function renderErrorPage(options, currentUser) {
|
||||
return renderView('error/error', {
|
||||
title: String(errorOptions.title || copy.title || 'Error').trim(),
|
||||
active: '',
|
||||
messageVariant: 'primary',
|
||||
messageVariant: statusCode === 403 ? 'warning' : 'primary',
|
||||
currentUser: currentUser || null,
|
||||
statusCode: statusCode,
|
||||
errorTitle: String(errorOptions.errorTitle || copy.errorTitle || errorOptions.title || 'Error').trim(),
|
||||
|
||||
@@ -1,43 +0,0 @@
|
||||
// Aggregate the primary web route registrations.
|
||||
|
||||
module.exports = {
|
||||
renderLoginPage: require('./auth/login'),
|
||||
renderAccountPage: require('./account/password'),
|
||||
renderUsersPage: require('./users/list'),
|
||||
renderUsersAddPage: require('./users/add'),
|
||||
renderUsersEditPage: require('./users/edit'),
|
||||
renderDashboardPage: require('./dashboard/index'),
|
||||
renderConnectedClientsPage: require('./clients/list'),
|
||||
renderPlaylistsPage: require('./playlists/list'),
|
||||
renderPlaylistFormPage: require('./playlists/add'),
|
||||
renderPlaylistEditPage: require('./playlists/edit'),
|
||||
renderPlaylistSlideConfigPage: require('./playlists/slide-config'),
|
||||
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'),
|
||||
renderScreensPage: require('./screens/list'),
|
||||
renderScreenFormPage: require('./screens/add'),
|
||||
renderScreenEditPage: require('./screens/edit'),
|
||||
renderAnnouncementsPage: require('./announcements/list'),
|
||||
renderAnnouncementFormPage: require('./announcements/add'),
|
||||
renderAnnouncementEditPage: require('./announcements/edit'),
|
||||
renderSlidesPage: require('./slides/list'),
|
||||
renderSlideFormPage: require('./slides/form'),
|
||||
renderTemplatesPage: require('./templates/list'),
|
||||
renderTemplateFormPage: require('./templates/add'),
|
||||
renderTemplateEditPage: require('./templates/edit'),
|
||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
|
||||
renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage,
|
||||
renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage,
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require('./rbac/list'),
|
||||
renderRbacAddPage: require('./rbac/add'),
|
||||
renderRbacEditPage: require('./rbac/edit')
|
||||
};
|
||||
@@ -4,11 +4,15 @@ const registerAuthRoutes = require('./auth');
|
||||
const registerPagesRoutes = require('./admin/pages');
|
||||
const registerAccountRoutes = require('./admin/account');
|
||||
const registerUsersRoutes = require('./admin/users');
|
||||
const registerPlaylistRoutes = require('./admin/playlists');
|
||||
const registerManageRoutes = require('./admin/manage');
|
||||
const registerScreenCommandRoutes = require('./admin/client-commands');
|
||||
const registerAnnouncementRoutes = require('./signage/announcements/routes');
|
||||
const registerContentRoutes = require('./admin/content');
|
||||
const registerDataSourceRoutes = require('./admin/data-sources');
|
||||
const registerRootDataSourceRoutes = require('./admin/data-sources');
|
||||
const registerApiSourceRoutes = require('./data-sources/api-sources/routes');
|
||||
const registerRssFeedRoutes = require('./data-sources/rss-feeds/routes');
|
||||
const registerTimetableRoutes = require('./data-sources/timetables/routes');
|
||||
const registerSettingsRoutes = require('./settings/background-tasks');
|
||||
const registerFontRoutes = require('./settings/fonts');
|
||||
const { PERMISSIONS, normalizePermissionKeys } = require('#src/rbac');
|
||||
@@ -74,25 +78,32 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
|
||||
function registerSignageRoutes(app, deps) {
|
||||
// Screen, playlist, and command routes share the player-facing wiring.
|
||||
registerManageRoutes(app, {
|
||||
registerPlaylistRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
fetchOrderedPlaylistSlides: deps.fetchOrderedPlaylistSlides,
|
||||
fetchScreensByPlaylistId: deps.fetchScreensByPlaylistId,
|
||||
fetchPlaylistCanvasSignature: deps.fetchPlaylistCanvasSignature,
|
||||
getCanvasSignature: deps.getCanvasSignature,
|
||||
normalizeScheduleMode: deps.normalizeScheduleMode,
|
||||
parseDateTimeLocal: deps.parseDateTimeLocal,
|
||||
parseTimeLocal: deps.parseTimeLocal,
|
||||
fetchPlaylistCanvasId: deps.fetchPlaylistCanvasId,
|
||||
readArrayField: deps.readArrayField,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
notifyPlayerScreens: deps.notifyPlayerScreens,
|
||||
broadcastDashboardState: deps.broadcastDashboardState,
|
||||
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerManageRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
notifyPlayerScreens: deps.notifyPlayerScreens,
|
||||
broadcastDashboardState: deps.broadcastDashboardState,
|
||||
getScreenDeleteBlockMessage: deps.playerActionService.getScreenDeleteBlockMessage,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
getPlaylistDeleteBlockMessage: deps.playerActionService.getPlaylistDeleteBlockMessage,
|
||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||
playerPublicBaseUrl: deps.playerPublicBaseUrl,
|
||||
requirePermission: deps.requirePermission
|
||||
@@ -110,11 +121,13 @@ function registerSignageRoutes(app, deps) {
|
||||
|
||||
registerScreenCommandRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
forwardPlayerCommand: deps.playerActionService.forwardPlayerCommand,
|
||||
getScreenConnections: deps.playerActionService.getScreenConnections,
|
||||
isClientNameAvailable: deps.isClientNameAvailable,
|
||||
withClientNameReservation: deps.withClientNameReservation,
|
||||
broadcastDashboardState: deps.broadcastDashboardState,
|
||||
playerPublicBaseUrl: deps.playerPublicBaseUrl,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
}
|
||||
@@ -161,7 +174,50 @@ function registerSettingsAndContentRoutes(app, deps) {
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerDataSourceRoutes(app, {
|
||||
registerRootDataSourceRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
parseDateTimeLocal: deps.parseDateTimeLocal,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
dataSourceTasks: deps.dataSourceTasks,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
requirePermission: deps.requirePermission,
|
||||
section: 'root'
|
||||
});
|
||||
|
||||
registerApiSourceRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
parseDateTimeLocal: deps.parseDateTimeLocal,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
dataSourceTasks: deps.dataSourceTasks,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerRssFeedRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
redirectAfterSave: deps.redirectAfterSave,
|
||||
parseDateTimeLocal: deps.parseDateTimeLocal,
|
||||
backgroundTaskQueue: deps.backgroundTaskQueue,
|
||||
dataSourceTasks: deps.dataSourceTasks,
|
||||
setAuthMessageCookie: deps.setAuthMessageCookie,
|
||||
requirePermission: deps.requirePermission
|
||||
});
|
||||
|
||||
registerTimetableRoutes(app, {
|
||||
pool: deps.pool,
|
||||
common: deps.common,
|
||||
pages: deps.pages,
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
// Background tasks settings page renderer.
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const { buildPagination, buildQueryString } = require('../../lib/pagination');
|
||||
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
|
||||
const { renderView } = require('#src/web/view');
|
||||
const { hasAnyPermission } = require('#src/rbac');
|
||||
const { buildPagination, buildQueryString } = require('#src/web/lib/pagination');
|
||||
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('#src/web/lib/list-query');
|
||||
|
||||
const TASKS_PER_PAGE = 25;
|
||||
const QUEUE_PAGE_URL = '/settings/tasks-background';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// Settings route registration for the background tasks page.
|
||||
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
|
||||
function redirectToLogin(res, setAuthMessageCookie) {
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
@@ -21,7 +21,7 @@ function requireSettingsAccess(setAuthMessageCookie) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -38,7 +38,7 @@ function requireScheduledSettingsAccess(setAuthMessageCookie) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -86,7 +86,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
@@ -99,7 +99,7 @@ module.exports = function registerSettingsRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (!hasAnyPermission(req.currentUser, ['scheduled-tasks.allow'])) {
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
return next(error);
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
// Settings route registration for managed fonts.
|
||||
|
||||
const fs = require('fs');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
const fontLibrary = require('../../lib/media/font-library');
|
||||
const { hasAnyPermission, PERMISSION_DENIED_MESSAGE } = require('#src/rbac');
|
||||
const fontLibrary = require('#src/web/lib/media/font-library');
|
||||
|
||||
function normalizeFontFamilyName(value) {
|
||||
return String(value || '').replace(/\s+/g, ' ').trim().toLowerCase();
|
||||
@@ -62,7 +62,7 @@ function requireFontsAccess(setAuthMessageCookie) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
const error = new Error(PERMISSION_DENIED_MESSAGE);
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
|
||||
@@ -1,16 +1,8 @@
|
||||
// RBAC role add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRbacAddViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, messageVariant) {
|
||||
return renderView('settings/rbac/add', {
|
||||
title: 'Create role',
|
||||
active: 'rbac',
|
||||
message: message,
|
||||
messageVariant: messageVariant || 'primary',
|
||||
currentUser: currentUser || null,
|
||||
formValues: formValues || {},
|
||||
permissionGroups: permissionGroups || [],
|
||||
scripts: ['js/rbac-permissions.js']
|
||||
});
|
||||
module.exports = function renderRbacAddPage(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
|
||||
return renderView('settings/rbac/form', buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant));
|
||||
};
|
||||
@@ -1,19 +1,8 @@
|
||||
// RBAC role edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildRbacEditViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
|
||||
return renderView('settings/rbac/edit', {
|
||||
title: 'Edit role',
|
||||
active: 'rbac',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
role: role,
|
||||
inUse: Boolean(role && role.inUse),
|
||||
permissionGroups: permissionGroups || [],
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
|
||||
scripts: ['js/rbac-permissions.js']
|
||||
});
|
||||
return renderView('settings/rbac/form', buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination));
|
||||
};
|
||||
@@ -0,0 +1,59 @@
|
||||
// Shared RBAC form view-model helpers.
|
||||
|
||||
function buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
|
||||
return {
|
||||
title: 'Create role',
|
||||
active: 'rbac',
|
||||
isEdit: false,
|
||||
usersPresent: true,
|
||||
formId: 'role-create-form',
|
||||
formAction: '/rbac',
|
||||
footerSaveUrl: '/rbac',
|
||||
footerCancelUrl: '/rbac',
|
||||
footerDeleteUrl: '',
|
||||
footerDeleteDisabled: true,
|
||||
footerDeleteTitle: 'Delete is available after the role has been created.',
|
||||
footerShowDelete: true,
|
||||
message: message,
|
||||
messageVariant: messageVariant || 'primary',
|
||||
currentUser: currentUser || null,
|
||||
formValues: formValues || {},
|
||||
permissionGroups: permissionGroups || [],
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
roleEditBasePath: '/rbac/new',
|
||||
scripts: ['js/rbac-permissions.js'],
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url"'
|
||||
};
|
||||
}
|
||||
|
||||
function buildRbacEditViewModel(role, message, currentUser, permissionGroups, users, pagination) {
|
||||
return {
|
||||
title: 'Edit role',
|
||||
active: 'rbac',
|
||||
isEdit: true,
|
||||
usersPresent: true,
|
||||
formId: 'role-edit-form',
|
||||
formAction: '/rbac/' + Number(role && role.id),
|
||||
footerSaveUrl: '/rbac/' + Number(role && role.id),
|
||||
footerCancelUrl: '/rbac',
|
||||
footerDeleteUrl: '/rbac/' + Number(role && role.id) + '/delete',
|
||||
footerDeleteDisabled: Boolean(role && role.inUse),
|
||||
footerDeleteTitle: role && role.inUse ? 'Delete is disabled while this role is assigned to users.' : '',
|
||||
footerShowDelete: true,
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
role: role,
|
||||
inUse: Boolean(role && role.inUse),
|
||||
permissionGroups: permissionGroups || [],
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
|
||||
scripts: ['js/rbac-permissions.js']
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildRbacAddViewModel: buildRbacAddViewModel,
|
||||
buildRbacEditViewModel: buildRbacEditViewModel
|
||||
};
|
||||
@@ -1,15 +1,8 @@
|
||||
// User add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildUsersAddViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderUsersAddPage(message, currentUser, roles, formValues, messageVariant) {
|
||||
return renderView('settings/users/add', {
|
||||
title: 'Add user',
|
||||
active: 'users',
|
||||
message: message,
|
||||
messageVariant: messageVariant || 'primary',
|
||||
currentUser: currentUser || null,
|
||||
roles: roles || [],
|
||||
formValues: formValues || {}
|
||||
});
|
||||
return renderView('settings/users/form', buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant));
|
||||
};
|
||||
@@ -1,15 +1,8 @@
|
||||
// User edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildUsersEditViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderUsersEditPage(user, message, currentUser, roles) {
|
||||
return renderView('settings/users/edit', {
|
||||
title: 'Edit user',
|
||||
active: 'users',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user,
|
||||
inUse: Boolean(user && user.inUse),
|
||||
roles: roles || []
|
||||
});
|
||||
return renderView('settings/users/form', buildUsersEditViewModel(user, message, currentUser, roles));
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
// Shared user form view-model helpers.
|
||||
|
||||
function normalizeSelectedRoleIds(selectedRoleIds) {
|
||||
return new Set((Array.isArray(selectedRoleIds) ? selectedRoleIds : []).map(function (roleId) {
|
||||
return Number(roleId);
|
||||
}).filter(function (roleId) {
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
}));
|
||||
}
|
||||
|
||||
function mapRolesForForm(roles, selectedRoleIds) {
|
||||
const selectedIds = normalizeSelectedRoleIds(selectedRoleIds);
|
||||
|
||||
return (Array.isArray(roles) ? roles : []).map(function (role) {
|
||||
return Object.assign({}, role, {
|
||||
isSelected: selectedIds.has(Number(role.id))
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildUsersAddViewModel(message, currentUser, roles, formValues, messageVariant) {
|
||||
return {
|
||||
title: 'Add user',
|
||||
active: 'users',
|
||||
isEdit: false,
|
||||
formId: 'user-form',
|
||||
formAction: '/users',
|
||||
footerSaveUrl: '/users',
|
||||
footerCancelUrl: '/users',
|
||||
footerDeleteUrl: '',
|
||||
footerDeleteDisabled: true,
|
||||
footerDeleteTitle: 'Delete is available after the user has been created.',
|
||||
footerShowDelete: true,
|
||||
message: message,
|
||||
messageVariant: messageVariant || 'primary',
|
||||
currentUser: currentUser || null,
|
||||
roles: mapRolesForForm(roles, []),
|
||||
formValues: formValues || {},
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-redirect="response-url" data-async-save-new-url="/users/new"'
|
||||
};
|
||||
}
|
||||
|
||||
function buildUsersEditViewModel(user, message, currentUser, roles) {
|
||||
return {
|
||||
title: 'Edit user',
|
||||
active: 'users',
|
||||
isEdit: true,
|
||||
formId: 'user-profile-form',
|
||||
formAction: '/users/' + user.id + '/username',
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/users"',
|
||||
footerSaveUrl: '/users/' + user.id + '/username',
|
||||
footerCancelUrl: '/users',
|
||||
footerDeleteUrl: '/users/' + user.id + '/delete',
|
||||
footerDeleteDisabled: Boolean(user && user.inUse),
|
||||
footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '',
|
||||
footerShowDelete: true,
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user,
|
||||
inUse: Boolean(user && user.inUse),
|
||||
roles: mapRolesForForm(roles, user && user.roleIds)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
mapRolesForForm: mapRolesForForm,
|
||||
buildUsersAddViewModel: buildUsersAddViewModel,
|
||||
buildUsersEditViewModel: buildUsersEditViewModel
|
||||
};
|
||||
@@ -1,41 +1,8 @@
|
||||
// Announcement add page renderer and form defaults.
|
||||
// Announcement add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
|
||||
const { buildAnnouncementFormViewModel } = require('./form-view-model');
|
||||
|
||||
function buildDefaultAnnouncement() {
|
||||
return {
|
||||
id: null,
|
||||
message: '',
|
||||
short_label: '',
|
||||
announcement_type: 'lower-third',
|
||||
color_key: 'primary',
|
||||
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
|
||||
durationMode: 'duration',
|
||||
duration_seconds: null,
|
||||
durationValue: 5,
|
||||
durationUnit: 'minutes',
|
||||
durationLabel: 'Until disabled',
|
||||
screen_ids: []
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderAnnouncementFormPage(announcement, mode, message, currentUser, options) {
|
||||
const isEdit = mode === 'edit';
|
||||
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
|
||||
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
|
||||
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
|
||||
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
|
||||
|
||||
return renderView(isEdit ? 'signage/announcements/edit' : 'signage/announcements/add', {
|
||||
title: isEdit ? 'Edit announcement' : 'Add announcement',
|
||||
active: 'announcements',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
announcement: Object.assign({}, buildDefaultAnnouncement(), announcement || {}),
|
||||
announcementTypes: announcementTypes,
|
||||
announcementColors: announcementColors,
|
||||
announcementIcons: announcementIcons,
|
||||
announcementScreens: announcementScreens
|
||||
});
|
||||
module.exports = function renderAnnouncementAddPage(announcement, message, currentUser, options) {
|
||||
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, false));
|
||||
};
|
||||
@@ -1,7 +1,8 @@
|
||||
// Announcement edit page renderer that reuses the add form.
|
||||
// Announcement edit page renderer.
|
||||
|
||||
const renderAnnouncementFormPage = require('./add');
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildAnnouncementFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderAnnouncementEditPage(announcement, _data, message, currentUser, options) {
|
||||
return renderAnnouncementFormPage(announcement, 'edit', message, currentUser, options);
|
||||
return renderView('signage/announcements/form', buildAnnouncementFormViewModel(announcement, message, currentUser, options, true));
|
||||
};
|
||||
@@ -0,0 +1,54 @@
|
||||
// Shared announcement form view-model builder.
|
||||
|
||||
const { DEFAULT_ANNOUNCEMENT_ICON } = require('#src/data/announcement-icons');
|
||||
|
||||
function buildDefaultAnnouncement() {
|
||||
return {
|
||||
id: null,
|
||||
message: '',
|
||||
short_label: '',
|
||||
announcement_type: 'lower-third',
|
||||
color_key: 'primary',
|
||||
icon_key: DEFAULT_ANNOUNCEMENT_ICON,
|
||||
durationMode: 'duration',
|
||||
duration_seconds: null,
|
||||
durationValue: 5,
|
||||
durationUnit: 'minutes',
|
||||
durationLabel: 'Until disabled',
|
||||
screen_ids: []
|
||||
};
|
||||
}
|
||||
|
||||
function buildAnnouncementFormViewModel(announcement, message, currentUser, options, isEdit) {
|
||||
const announcementTypes = Array.isArray(options && options.announcementTypes) ? options.announcementTypes : [];
|
||||
const announcementColors = Array.isArray(options && options.announcementColors) ? options.announcementColors : [];
|
||||
const announcementIcons = Array.isArray(options && options.announcementIcons) ? options.announcementIcons : [];
|
||||
const announcementScreens = Array.isArray(options && options.announcementScreens) ? options.announcementScreens : [];
|
||||
const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {});
|
||||
const canDelete = Boolean(currentUser && Array.isArray(currentUser.permissionKeys) && currentUser.permissionKeys.indexOf('announcements.delete') !== -1);
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit announcement' : 'Add announcement',
|
||||
active: 'announcements',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
announcement: viewAnnouncement,
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showDeleteAction: Boolean(isEdit && canDelete),
|
||||
deleteDisabled: !isEdit || !Boolean(viewAnnouncement && viewAnnouncement.canDelete),
|
||||
formAction: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id : '/announcements',
|
||||
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/announcements"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/announcements/new"',
|
||||
cancelUrl: '/announcements',
|
||||
deleteUrl: isEdit && viewAnnouncement && viewAnnouncement.id ? '/announcements/' + viewAnnouncement.id + '/delete' : '',
|
||||
actionFormId: 'announcement-action-form',
|
||||
announcementTypes: announcementTypes,
|
||||
announcementColors: announcementColors,
|
||||
announcementIcons: announcementIcons,
|
||||
announcementScreens: announcementScreens
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildAnnouncementFormViewModel: buildAnnouncementFormViewModel
|
||||
};
|
||||
@@ -261,7 +261,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
app.get('/announcements/new', requirePermission('announcements.create'), async function (req, res, next) {
|
||||
try {
|
||||
const screens = await fetchAnnouncementScreens();
|
||||
res.send(pages.renderAnnouncementFormPage(buildAnnouncementFormData({
|
||||
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData({
|
||||
message: '',
|
||||
short_label: '',
|
||||
announcement_type: 'lower-third',
|
||||
@@ -272,7 +272,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
durationValue: 5,
|
||||
durationUnit: 'minutes',
|
||||
screen_ids: []
|
||||
}), 'add', req.query.message ? String(req.query.message) : '', req.currentUser, {
|
||||
}), req.query.message ? String(req.query.message) : '', req.currentUser, {
|
||||
announcementTypes: ANNOUNCEMENT_TYPES,
|
||||
announcementColors: ANNOUNCEMENT_COLORS,
|
||||
announcementIcons: ANNOUNCEMENT_ICONS,
|
||||
|
||||
@@ -1,24 +1,8 @@
|
||||
// Canvas size add page renderer and form defaults.
|
||||
// Canvas size add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
|
||||
|
||||
function buildDefaultCanvasSize() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
width: 1920,
|
||||
height: 1080
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
return renderView(isEdit ? 'canvas-sizes/edit' : 'canvas-sizes/add', {
|
||||
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
|
||||
active: 'canvas-sizes',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
canvasSize: canvasSize || buildDefaultCanvasSize(),
|
||||
inUse: Boolean(canvasSize && canvasSize.inUse)
|
||||
});
|
||||
module.exports = function renderCanvasSizeAddPage(canvasSize, message, currentUser) {
|
||||
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, false));
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Canvas size edit page renderer that reuses the add form.
|
||||
// Canvas size edit page renderer.
|
||||
|
||||
const renderCanvasSizeFormPage = require('./add');
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildCanvasSizeFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
|
||||
return renderCanvasSizeFormPage(canvasSize, 'edit', message, currentUser);
|
||||
return renderView('canvas-sizes/form', buildCanvasSizeFormViewModel(canvasSize, message, currentUser, true));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
// Shared canvas size form view-model builder.
|
||||
|
||||
function buildDefaultCanvasSize() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
width: 1920,
|
||||
height: 1080
|
||||
};
|
||||
}
|
||||
|
||||
function buildCanvasSizeFormViewModel(canvasSize, message, currentUser, isEdit) {
|
||||
const viewCanvasSize = Object.assign(buildDefaultCanvasSize(), canvasSize || {});
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
|
||||
active: 'canvas-sizes',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
canvasSize: viewCanvasSize,
|
||||
inUse: Boolean(viewCanvasSize && viewCanvasSize.inUse),
|
||||
isEdit: Boolean(isEdit),
|
||||
formAction: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id : '/canvas-sizes',
|
||||
formAttrs: 'data-async-save' + (isEdit ? ' data-async-save-close-url="/canvas-sizes"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/canvas-sizes/new"',
|
||||
cancelUrl: '/canvas-sizes',
|
||||
deleteUrl: isEdit && viewCanvasSize ? '/canvas-sizes/' + viewCanvasSize.id + '/delete' : '',
|
||||
deleteConfirmMessage: 'Delete this canvas size?',
|
||||
deleteDisabled: !isEdit || Boolean(viewCanvasSize && viewCanvasSize.inUse)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildCanvasSizeFormViewModel: buildCanvasSizeFormViewModel
|
||||
};
|
||||
@@ -9,6 +9,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser)
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/dashboard/dashboard-page.js'],
|
||||
screens: data.screens || [],
|
||||
clients: data.clients || [],
|
||||
pagination: data.pagination || null
|
||||
});
|
||||
|
||||
@@ -60,6 +60,7 @@ module.exports = function registerClientsRoutes(app, deps) {
|
||||
const startIndex = (page - 1) * LIST_PAGE_SIZE;
|
||||
const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
|
||||
res.send(pages.renderConnectedClientsPage({
|
||||
screens: data.screens || [],
|
||||
clients: clients,
|
||||
pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages')
|
||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
|
||||
@@ -1,17 +1,9 @@
|
||||
// Playlist add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const renderPlaylistEditorPage = require('./form-page');
|
||||
|
||||
module.exports = function renderPlaylistFormPage(message, currentUser) {
|
||||
return renderView('playlists/add', {
|
||||
title: 'Create playlist',
|
||||
active: 'playlists',
|
||||
playlist: {
|
||||
fade_between_slides: false,
|
||||
skip_unavailable_rtmp: false
|
||||
},
|
||||
message: message,
|
||||
currentUser: currentUser || null
|
||||
});
|
||||
module.exports = async function renderPlaylistFormPage(message, currentUser, data, pool) {
|
||||
const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] });
|
||||
return renderPlaylistEditorPage(null, adminData, message, currentUser, { isEdit: false });
|
||||
};
|
||||
|
||||
|
||||
@@ -1,226 +1,7 @@
|
||||
// Playlist edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
return value ? String(value).slice(0, 5) : '';
|
||||
}
|
||||
|
||||
function formatDays(daysJson) {
|
||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
let days = [];
|
||||
if (Array.isArray(daysJson)) {
|
||||
days = daysJson;
|
||||
} else if (daysJson) {
|
||||
try {
|
||||
const parsed = JSON.parse(daysJson);
|
||||
days = Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
days = [];
|
||||
}
|
||||
}
|
||||
return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function normalizeScheduleDaysJson(daysJson) {
|
||||
if (Array.isArray(daysJson)) {
|
||||
return JSON.stringify(daysJson.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
|
||||
}
|
||||
|
||||
if (!daysJson) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(daysJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return JSON.stringify(parsed.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
|
||||
}
|
||||
} catch (_error) {
|
||||
return JSON.stringify(String(daysJson).split(',').map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
|
||||
}
|
||||
|
||||
return '[]';
|
||||
}
|
||||
|
||||
function scheduleSummary(item) {
|
||||
const mode = String(item.schedule_mode || 'always');
|
||||
if (mode === 'dates') {
|
||||
const start = formatDateTime(item.schedule_start_datetime);
|
||||
const end = formatDateTime(item.schedule_end_datetime);
|
||||
return start && end ? `Dates: ${start} to ${end}` : 'Dates: not set';
|
||||
}
|
||||
if (mode === 'times') {
|
||||
const days = formatDays(item.schedule_days_json);
|
||||
const start = formatTime(item.schedule_start_time);
|
||||
const end = formatTime(item.schedule_end_time);
|
||||
return days && start && end ? `Times: ${days} ${start}-${end}` : 'Times: not set';
|
||||
}
|
||||
return 'Always visible';
|
||||
}
|
||||
|
||||
function getCanvasSignature(width, height) {
|
||||
const normalizedWidth = Number(width);
|
||||
const normalizedHeight = Number(height);
|
||||
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
|
||||
return null;
|
||||
}
|
||||
return normalizedWidth + 'x' + normalizedHeight;
|
||||
}
|
||||
|
||||
function hasVideoRegion(contentJson) {
|
||||
if (!contentJson) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
|
||||
const region = parsed[key];
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
|
||||
}));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoSourcePath(slide) {
|
||||
if (!slide) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const contentJson = slide.content_json;
|
||||
if (!contentJson) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
|
||||
});
|
||||
|
||||
return videoRegion ? String(videoRegion.value || '').trim() : '';
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoDurationSeconds(slide) {
|
||||
if (!slide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentJson = slide.content_json;
|
||||
if (!contentJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoDurationValue(slide) {
|
||||
const storedDuration = Number(slide && slide.duration_seconds || 0);
|
||||
const videoDuration = getVideoDurationSeconds(slide);
|
||||
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
|
||||
}
|
||||
const renderPlaylistEditorPage = require('./form-page');
|
||||
|
||||
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
|
||||
const playlistSlides = (data.playlistSlides || [])
|
||||
.filter((item) => item.playlist_id === playlist.id)
|
||||
.sort((a, b) => {
|
||||
if (a.position !== b.position) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.id - b.id;
|
||||
})
|
||||
.map((item, index, items) => Object.assign({}, item, {
|
||||
displayOrder: index + 1,
|
||||
isFirst: index === 0,
|
||||
isLast: index === items.length - 1,
|
||||
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
|
||||
showVideoDurationButton: hasVideoRegion(item.content_json),
|
||||
videoSourcePath: getVideoSourcePath(item),
|
||||
videoDurationSeconds: getVideoDurationSeconds(item),
|
||||
useVideoDuration: Boolean(item.use_video_duration),
|
||||
durationSeconds: getVideoDurationValue(item),
|
||||
schedule_days_json: normalizeScheduleDaysJson(item.schedule_days_json),
|
||||
scheduleSummary: scheduleSummary(item)
|
||||
}));
|
||||
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
||||
const playlistCanvasSignatures = Array.from(new Set(playlistSlides.map((item) => getCanvasSignature(item.canvas_width, item.canvas_height)).filter(Boolean)));
|
||||
const playlistCanvasSignature = playlistCanvasSignatures.length === 1 ? playlistCanvasSignatures[0] : null;
|
||||
const playlistCanvasMismatch = playlistCanvasSignatures.length > 1;
|
||||
const selectableSlides = (data.slides || []).filter((slide) => {
|
||||
if (playlistCanvasMismatch) {
|
||||
return false;
|
||||
}
|
||||
if (!playlistCanvasSignature) {
|
||||
return true;
|
||||
}
|
||||
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
|
||||
}).map((slide) => Object.assign({}, slide, {
|
||||
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
|
||||
isAssigned: assignedSlideIds.has(slide.id),
|
||||
showVideoDurationButton: hasVideoRegion(slide.content_json),
|
||||
videoSourcePath: getVideoSourcePath(slide),
|
||||
videoDurationSeconds: getVideoDurationSeconds(slide),
|
||||
useVideoDuration: Boolean(slide.use_video_duration),
|
||||
durationSeconds: getVideoDurationValue(slide)
|
||||
}));
|
||||
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
|
||||
|
||||
return renderView('playlists/edit', {
|
||||
title: 'Edit playlist',
|
||||
active: 'playlists',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlist: playlist,
|
||||
inUse: Boolean(playlist.inUse),
|
||||
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||
playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||
playlistSlides: playlistSlides,
|
||||
slides: data.slides || [],
|
||||
hasSlides: (data.slides || []).length > 0,
|
||||
hasSelectableSlides: selectableSlides.length > 0,
|
||||
hasAddableSlides: hasAddableSlides,
|
||||
selectableSlides: selectableSlides,
|
||||
playlistCanvasSignature: playlistCanvasSignature,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
scripts: ['js/lib/modal.js?v=20260726.7', 'vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
|
||||
});
|
||||
return renderPlaylistEditorPage(playlist, data || {}, message, currentUser, { isEdit: true });
|
||||
};
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
// Shared playlist form page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildPlaylistFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderPlaylistFormPage(playlist, data, message, currentUser, options) {
|
||||
return renderView('playlists/form', buildPlaylistFormViewModel(playlist || null, data || {}, message, currentUser, options || {}));
|
||||
};
|
||||
@@ -0,0 +1,289 @@
|
||||
// Shared playlist form view-model builder.
|
||||
|
||||
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
|
||||
|
||||
function formatDateTime(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
const year = date.getFullYear();
|
||||
const month = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(date.getDate()).padStart(2, '0');
|
||||
const hours = String(date.getHours()).padStart(2, '0');
|
||||
const minutes = String(date.getMinutes()).padStart(2, '0');
|
||||
return `${year}-${month}-${day} ${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function formatTime(value) {
|
||||
return value ? String(value).slice(0, 5) : '';
|
||||
}
|
||||
|
||||
function scheduleSummary(item) {
|
||||
return formatScheduleRulesSummary(item && item.scheduleRules ? item.scheduleRules : []);
|
||||
}
|
||||
|
||||
function formatScheduleRuleForView(rule, index) {
|
||||
const input = rule && typeof rule === 'object' ? rule : {};
|
||||
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
const selectedDays = new Set(normalizeDayValues(input.days || input.schedule_days_json));
|
||||
const normalizedDays = Array.from(selectedDays);
|
||||
|
||||
return {
|
||||
index: index + 1,
|
||||
position: index,
|
||||
startDateTimeValue: formatDateTime(input.start_datetime),
|
||||
endDateTimeValue: formatDateTime(input.end_datetime),
|
||||
startTimeValue: formatTime(input.start_time),
|
||||
endTimeValue: formatTime(input.end_time),
|
||||
daysCsv: normalizedDays.join(','),
|
||||
ruleSummary: formatScheduleRuleSummary(input),
|
||||
dayOptions: dayNames.map((label, value) => ({
|
||||
value: value,
|
||||
label: label,
|
||||
checked: selectedDays.has(value),
|
||||
inputId: 'schedule-rule-' + (index + 1) + '-day-' + value
|
||||
}))
|
||||
};
|
||||
}
|
||||
|
||||
function getCanvasSignature(width, height) {
|
||||
const normalizedWidth = Number(width);
|
||||
const normalizedHeight = Number(height);
|
||||
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
|
||||
return null;
|
||||
}
|
||||
return normalizedWidth + 'x' + normalizedHeight;
|
||||
}
|
||||
|
||||
function hasVideoRegion(contentJson) {
|
||||
if (!contentJson) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
|
||||
const region = parsed[key];
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
|
||||
}));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function hasPlayableMediaRegion(contentJson) {
|
||||
if (!contentJson) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
|
||||
const region = parsed[key];
|
||||
const regionType = String(region && region.type || '').trim().toLowerCase();
|
||||
return region && typeof region === 'object' && (regionType === 'video' || regionType === 'rtmp');
|
||||
}));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getDisableAudioValue(slide) {
|
||||
if (!slide || slide.disable_audio === undefined || slide.disable_audio === null) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return Boolean(slide.disable_audio);
|
||||
}
|
||||
|
||||
function getVideoSourcePath(slide) {
|
||||
if (!slide) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const contentJson = slide.content_json;
|
||||
if (!contentJson) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return '';
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
|
||||
});
|
||||
|
||||
return videoRegion ? String(videoRegion.value || '').trim() : '';
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoDurationSeconds(slide) {
|
||||
if (!slide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentJson = slide.content_json;
|
||||
if (!contentJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoDurationValue(slide) {
|
||||
const storedDuration = Number(slide && slide.duration_seconds || 0);
|
||||
const videoDuration = getVideoDurationSeconds(slide);
|
||||
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
|
||||
}
|
||||
|
||||
function formatCanvasSizeLabel(size) {
|
||||
const width = Number(size && size.width);
|
||||
const height = Number(size && size.height);
|
||||
const dimensions = Number.isFinite(width) && Number.isFinite(height) ? width + 'x' + height : '';
|
||||
const name = String(size && size.name ? size.name : '').trim();
|
||||
if (!dimensions) {
|
||||
return name;
|
||||
}
|
||||
return name ? dimensions + ' - ' + name : dimensions;
|
||||
}
|
||||
|
||||
function buildPlaylistFormViewModel(playlist, data, message, currentUser, options) {
|
||||
const isEdit = Boolean(options && options.isEdit);
|
||||
const viewPlaylist = Object.assign({
|
||||
id: null,
|
||||
name: '',
|
||||
fade_between_slides: false,
|
||||
skip_unavailable_rtmp: false
|
||||
}, playlist || {});
|
||||
const playlistSlides = (data.playlistSlides || [])
|
||||
.filter((item) => Number(item.playlist_id) === Number(viewPlaylist.id))
|
||||
.sort((a, b) => {
|
||||
if (a.position !== b.position) {
|
||||
return a.position - b.position;
|
||||
}
|
||||
return a.id - b.id;
|
||||
})
|
||||
.map((item, index, items) => Object.assign({}, item, {
|
||||
row_key: String(item.row_key || item.id || ''),
|
||||
displayOrder: index + 1,
|
||||
isFirst: index === 0,
|
||||
isLast: index === items.length - 1,
|
||||
canvasSizeId: Number(item.canvas_size_id) || null,
|
||||
showVideoDurationButton: hasVideoRegion(item.content_json),
|
||||
showMuteButton: hasVideoRegion(item.content_json),
|
||||
videoSourcePath: getVideoSourcePath(item),
|
||||
videoDurationSeconds: getVideoDurationSeconds(item),
|
||||
useVideoDuration: Boolean(item.use_video_duration),
|
||||
disableAudio: getDisableAudioValue(item),
|
||||
durationSeconds: getVideoDurationValue(item),
|
||||
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
|
||||
scheduleSummary: scheduleSummary(item)
|
||||
}));
|
||||
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
||||
const storedCanvasId = Number(viewPlaylist.canvas_id) || null;
|
||||
const playlistSlideCanvasIds = Array.from(new Set(playlistSlides.map((item) => Number(item.canvasSizeId || item.canvas_size_id)).filter((value) => Number.isInteger(value) && value > 0)));
|
||||
const computedPlaylistCanvasId = playlistSlideCanvasIds.length === 1 ? playlistSlideCanvasIds[0] : null;
|
||||
const playlistCanvasId = storedCanvasId || computedPlaylistCanvasId;
|
||||
const selectedCanvasSize = (data.canvasSizes || []).find((size) => Number(size.id) === Number(playlistCanvasId)) || null;
|
||||
const canvasSizes = (data.canvasSizes || []).map((size) => Object.assign({}, size, {
|
||||
label: formatCanvasSizeLabel(size),
|
||||
canvasId: Number(size.id) || null,
|
||||
selected: Boolean(selectedCanvasSize && Number(selectedCanvasSize.id) === Number(size.id))
|
||||
}));
|
||||
const playlistCanvasMismatch = Boolean(
|
||||
(storedCanvasId && playlistSlides.some((item) => Number(item.canvasSizeId || item.canvas_size_id) > 0 && Number(item.canvasSizeId || item.canvas_size_id) !== storedCanvasId)) ||
|
||||
(!storedCanvasId && playlistSlideCanvasIds.length > 1)
|
||||
);
|
||||
const selectableSlides = (data.slides || []).map((slide) => Object.assign({}, slide, {
|
||||
canvasSizeId: Number(slide.canvas_size_id) || null,
|
||||
isAssigned: assignedSlideIds.has(slide.id),
|
||||
showVideoDurationButton: hasVideoRegion(slide.content_json),
|
||||
showMuteButton: hasVideoRegion(slide.content_json),
|
||||
videoSourcePath: getVideoSourcePath(slide),
|
||||
videoDurationSeconds: getVideoDurationSeconds(slide),
|
||||
useVideoDuration: Boolean(slide.use_video_duration),
|
||||
durationSeconds: getVideoDurationValue(slide)
|
||||
}));
|
||||
const playlistSlideTemplate = {
|
||||
id: '',
|
||||
row_key: '',
|
||||
slide_id: '',
|
||||
title: '',
|
||||
thumbnail_path: '',
|
||||
canvas_width: '',
|
||||
canvas_height: '',
|
||||
canvasSizeId: '',
|
||||
showVideoDurationButton: false,
|
||||
showMuteButton: false,
|
||||
videoSourcePath: '',
|
||||
videoDurationSeconds: '',
|
||||
useVideoDuration: false,
|
||||
disableAudio: true,
|
||||
durationSeconds: 10,
|
||||
scheduleRules: [],
|
||||
scheduleSummary: 'Always visible',
|
||||
displayOrder: ''
|
||||
};
|
||||
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned && (!playlistCanvasId || slide.canvasSizeId === playlistCanvasId));
|
||||
const isCreate = !isEdit;
|
||||
|
||||
return {
|
||||
title: 'Playlist editor',
|
||||
active: 'playlists',
|
||||
isEdit: isEdit,
|
||||
playlist: viewPlaylist,
|
||||
formAction: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id : '/playlists',
|
||||
formAttrs: 'data-async-save data-async-save-refresh-target="#playlist-items-body" data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new" data-async-save-new-redirect="response-url"',
|
||||
cancelUrl: '/playlists',
|
||||
deleteUrl: isEdit && viewPlaylist.id ? '/playlists/' + viewPlaylist.id + '/delete' : '',
|
||||
deleteDisabled: isCreate ? true : Boolean(viewPlaylist.inUse),
|
||||
showSaveSecondaryActions: true,
|
||||
playlistFadeBetweenSlides: Boolean(viewPlaylist.fade_between_slides),
|
||||
playlistSkipUnavailableRtmp: Boolean(viewPlaylist.skip_unavailable_rtmp),
|
||||
canvasSizes: canvasSizes,
|
||||
playlistCanvasSizeId: selectedCanvasSize ? Number(selectedCanvasSize.id) : '',
|
||||
playlistCanvasSizeLabel: selectedCanvasSize ? formatCanvasSizeLabel(selectedCanvasSize) : '',
|
||||
playlistCanvasEditable: Boolean(!isEdit || !playlistCanvasId),
|
||||
playlistScheduleConfigUrl: '/playlists/new/slides/0/config',
|
||||
hasSlides: (data.slides || []).length > 0,
|
||||
hasSelectableSlides: selectableSlides.length > 0,
|
||||
hasAddableSlides: hasAddableSlides,
|
||||
playlistSlides: playlistSlides,
|
||||
playlistSlideTemplate: playlistSlideTemplate,
|
||||
slides: data.slides || [],
|
||||
selectableSlides: selectableSlides,
|
||||
playlistCanvasId: playlistCanvasId,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260803.1'],
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
inUse: Boolean(viewPlaylist.inUse)
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildPlaylistFormViewModel: buildPlaylistFormViewModel
|
||||
};
|
||||
@@ -43,7 +43,12 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
});
|
||||
|
||||
app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) {
|
||||
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
(async function () {
|
||||
const data = await common.fetchAdminData(pool);
|
||||
res.send(await pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser, data, pool));
|
||||
})().catch(function (error) {
|
||||
res.status(500).send(error.message || 'Unable to load page.');
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Shared playlist slide configuration rendering helpers.
|
||||
|
||||
const { renderFragment } = require('../../../view');
|
||||
const { formatScheduleRuleSummary, formatScheduleRulesSummary, normalizeDayValues } = require('#src/web/lib/playlist-schedule');
|
||||
|
||||
const DAY_OPTIONS = [
|
||||
{ value: 0, label: 'Sun' },
|
||||
@@ -41,17 +42,34 @@ function formatTimeInput(value) {
|
||||
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
|
||||
const slide = playlistSlide || {
|
||||
id: 0,
|
||||
schedule_mode: 'always',
|
||||
schedule_days: [],
|
||||
schedule_start_datetime: null,
|
||||
schedule_end_datetime: null,
|
||||
schedule_start_time: null,
|
||||
schedule_end_time: null
|
||||
scheduleRules: []
|
||||
};
|
||||
const scheduleMode = String(slide.schedule_mode || 'always');
|
||||
const selectedDays = new Set(Array.isArray(slide.schedule_days) ? slide.schedule_days.map(Number) : []);
|
||||
const startTimeValue = formatTimeInput(slide.schedule_start_time) || (scheduleMode === 'times' ? DEFAULT_START_TIME : '');
|
||||
const endTimeValue = formatTimeInput(slide.schedule_end_time) || (scheduleMode === 'times' ? DEFAULT_END_TIME : '');
|
||||
const scheduleRules = Array.isArray(slide.scheduleRules) ? slide.scheduleRules : [];
|
||||
const renderedRules = scheduleRules.map((rule, index) => {
|
||||
const ruleNumber = index + 1;
|
||||
const selectedDays = new Set(normalizeDayValues(rule.days || rule.schedule_days_json));
|
||||
|
||||
return {
|
||||
id: 'schedule-rule-' + ruleNumber,
|
||||
index: ruleNumber,
|
||||
startDateTimeValue: formatDateTimeInput(rule.start_datetime),
|
||||
endDateTimeValue: formatDateTimeInput(rule.end_datetime),
|
||||
startTimeValue: formatTimeInput(rule.start_time),
|
||||
endTimeValue: formatTimeInput(rule.end_time),
|
||||
ruleSummary: formatScheduleRuleSummary(rule),
|
||||
dayOptions: DAY_OPTIONS.map((day) => ({
|
||||
...day,
|
||||
checked: selectedDays.has(day.value),
|
||||
inputId: 'schedule-rule-' + ruleNumber + '-day-' + day.value
|
||||
}))
|
||||
};
|
||||
});
|
||||
|
||||
const templateDayOptions = DAY_OPTIONS.map((day) => ({
|
||||
...day,
|
||||
checked: false,
|
||||
inputId: 'schedule-rule-template-day-' + day.value
|
||||
}));
|
||||
|
||||
return renderFragment('playlists/slide-config', {
|
||||
title: 'Slide schedule',
|
||||
@@ -63,19 +81,10 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
|
||||
currentUser: currentUser || null,
|
||||
playlist: playlist,
|
||||
playlistSlide: slide,
|
||||
scheduleMode: scheduleMode,
|
||||
isAlwaysMode: scheduleMode === 'always',
|
||||
isDatesMode: scheduleMode === 'dates',
|
||||
isTimesMode: scheduleMode === 'times',
|
||||
rowKey: String(rowKey || ''),
|
||||
startDateTimeValue: formatDateTimeInput(slide.schedule_start_datetime),
|
||||
endDateTimeValue: formatDateTimeInput(slide.schedule_end_datetime),
|
||||
startTimeValue: startTimeValue,
|
||||
endTimeValue: endTimeValue,
|
||||
dayOptions: DAY_OPTIONS.map((day) => ({
|
||||
...day,
|
||||
checked: selectedDays.has(day.value)
|
||||
})),
|
||||
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js']
|
||||
scheduleRules: renderedRules,
|
||||
templateDayOptions: templateDayOptions,
|
||||
scheduleRulesSummary: formatScheduleRulesSummary(scheduleRules),
|
||||
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js?v=20260802.8']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
// Screen add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildScreenFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderScreenFormPage(data, message, currentUser) {
|
||||
return renderView('screens/add', {
|
||||
title: 'Add screen',
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlists: data.playlists || []
|
||||
});
|
||||
return renderView('screens/form', buildScreenFormViewModel(null, data || {}, message, currentUser, false));
|
||||
};
|
||||
|
||||
|
||||
@@ -1,16 +1,9 @@
|
||||
// Screen edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildScreenFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
|
||||
return renderView('screens/edit', {
|
||||
title: 'Edit screen',
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
screen: screen,
|
||||
inUse: Boolean(screen.inUse),
|
||||
playlists: data.playlists || []
|
||||
});
|
||||
return renderView('screens/form', buildScreenFormViewModel(screen, data || {}, message, currentUser, true));
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Shared screen form view-model builder.
|
||||
|
||||
function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
|
||||
const viewScreen = Object.assign({
|
||||
name: '',
|
||||
slug: '',
|
||||
playlist_id: null
|
||||
}, screen || {});
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit screen' : 'Add screen',
|
||||
active: 'screens',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
playlists: (data && data.playlists) || [],
|
||||
screen: viewScreen,
|
||||
inUse: Boolean(viewScreen.inUse),
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
|
||||
formAction: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id : '/screens',
|
||||
formAttrs: Boolean(isEdit)
|
||||
? 'data-async-save data-async-save-close-url="/screens" data-async-save-new-url="/screens/new"'
|
||||
: 'data-async-save data-async-save-new-redirect="response-url" data-async-save-new-url="/screens/new"',
|
||||
cancelUrl: '/screens',
|
||||
deleteUrl: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id + '/delete' : ''
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildScreenFormViewModel: buildScreenFormViewModel
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Slide add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildSlideFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderSlideAddPage(data, message, currentUser) {
|
||||
return renderView('slides/form', buildSlideFormViewModel(data || {}, null, message, currentUser, false));
|
||||
};
|
||||
@@ -0,0 +1,8 @@
|
||||
// Slide edit page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildSlideFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderSlideEditPage(data, slide, message, currentUser) {
|
||||
return renderView('slides/form', buildSlideFormViewModel(data || {}, slide || null, message, currentUser, true));
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Shared slide form view-model builder.
|
||||
|
||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||
|
||||
function buildDefaultSlide() {
|
||||
return {
|
||||
title: '',
|
||||
template_id: null,
|
||||
content: {}
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplates(templates, templateRegions) {
|
||||
const regions = Array.isArray(templateRegions) ? templateRegions : [];
|
||||
return (Array.isArray(templates) ? templates : []).map((template) => Object.assign({}, template, {
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
canvas_size_height: template.canvas_size_height,
|
||||
regions: regions.filter((region) => Number(region.template_id) === Number(template.id))
|
||||
}));
|
||||
}
|
||||
|
||||
function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
|
||||
const assetVersion = Date.now().toString(36);
|
||||
const templateRegions = data && data.templateRegions ? data.templateRegions : [];
|
||||
const rssFeeds = data && data.rssFeeds ? data.rssFeeds : [];
|
||||
const apiSources = data && data.apiSources ? data.apiSources : [];
|
||||
const timetableGroups = data && data.timetableGroups ? data.timetableGroups : [];
|
||||
const fontLibrary = data && data.fontLibrary ? data.fontLibrary : null;
|
||||
const templates = buildTemplates(data && data.templates ? data.templates : [], templateRegions);
|
||||
const viewSlide = slide || buildDefaultSlide();
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit slide' : 'Create slide',
|
||||
active: 'slides',
|
||||
message: message,
|
||||
isEdit: Boolean(isEdit),
|
||||
inUse: Boolean(viewSlide && viewSlide.inUse),
|
||||
action: isEdit && viewSlide.id ? '/slides/' + viewSlide.id : '/slides',
|
||||
formAttrs: isEdit
|
||||
? 'data-async-save data-async-save-close-url="/slides" data-async-save-new-url="/slides/new"'
|
||||
: 'data-async-save data-async-save-close-url="/slides" data-async-save-new-redirect="response-url" data-async-save-new-url="/slides/new"',
|
||||
slide: viewSlide,
|
||||
templates: templates,
|
||||
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
|
||||
slideEditorData: {
|
||||
templates: templates,
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
timetableGroups: timetableGroups,
|
||||
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
|
||||
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
|
||||
existingTemplateId: viewSlide && viewSlide.template_id ? viewSlide.template_id : null,
|
||||
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}
|
||||
},
|
||||
assetVersion: assetVersion,
|
||||
slideEditorScripts: getRegionEditorScripts(assetVersion),
|
||||
currentUser: currentUser || null
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildSlideFormViewModel: buildSlideFormViewModel
|
||||
};
|
||||
@@ -1,45 +0,0 @@
|
||||
// Slide form page renderer for add and edit flows.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||
|
||||
module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) {
|
||||
const assetVersion = Date.now().toString(36);
|
||||
const templateRegions = data.templateRegions || [];
|
||||
const rssFeeds = data.rssFeeds || [];
|
||||
const apiSources = data.apiSources || [];
|
||||
const scheduleGroups = data.scheduleGroups || [];
|
||||
const fontLibrary = data.fontLibrary || null;
|
||||
const templates = (data.templates || []).map((template) => ({
|
||||
...template,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
canvas_size_height: template.canvas_size_height,
|
||||
regions: templateRegions.filter((region) => Number(region.template_id) === Number(template.id))
|
||||
}));
|
||||
return renderView('slides/form', {
|
||||
title: mode === 'edit' ? 'Edit slide' : 'Create slide',
|
||||
active: 'slides',
|
||||
message: message,
|
||||
isEdit: mode === 'edit',
|
||||
inUse: Boolean(slide && slide.inUse),
|
||||
saveLabel: 'Save',
|
||||
action: mode === 'edit' ? `/slides/${slide.id}` : '/slides',
|
||||
slide: slide || { title: '', template_id: null, content: {} },
|
||||
templates: templates,
|
||||
stylesheets: fontLibrary && fontLibrary.stylesheetHref ? [fontLibrary.stylesheetHref] : [],
|
||||
slideEditorData: {
|
||||
templates: templates,
|
||||
rssFeeds: rssFeeds,
|
||||
apiSources: apiSources,
|
||||
scheduleGroups: scheduleGroups,
|
||||
fontStylesheetHref: fontLibrary && fontLibrary.stylesheetHref ? fontLibrary.stylesheetHref : '',
|
||||
fontFamilyFormats: fontLibrary && fontLibrary.fontFamilyFormats ? fontLibrary.fontFamilyFormats : '',
|
||||
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
|
||||
existingContent: slide && slide.content ? slide.content : {}
|
||||
},
|
||||
assetVersion: assetVersion,
|
||||
slideEditorScripts: getRegionEditorScripts(assetVersion),
|
||||
currentUser: currentUser || null
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,61 +1,8 @@
|
||||
// Template add page renderer and form defaults.
|
||||
// Template add page renderer.
|
||||
|
||||
const { renderView } = require('../../../view');
|
||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||
const { buildTemplateFormViewModel } = require('./form-view-model');
|
||||
|
||||
function buildDefaultTemplate() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
canvas_size_id: null,
|
||||
canvas_size_width: 1920,
|
||||
canvas_size_height: 1080,
|
||||
background_color: '#111111',
|
||||
background_image_path: '',
|
||||
region_usage: [],
|
||||
regions: []
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTemplateCanvasSize(template, canvasSizes) {
|
||||
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
|
||||
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
|
||||
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
|
||||
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|
||||
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
|
||||
|
||||
if (matched) {
|
||||
return Object.assign({}, template, {
|
||||
canvas_size_id: matched.id,
|
||||
canvas_size_label: matched.name,
|
||||
canvas_size_width: matched.width,
|
||||
canvas_size_height: matched.height,
|
||||
background_color: String(template.background_color || '#111111').trim() || '#111111'
|
||||
});
|
||||
}
|
||||
|
||||
return Object.assign({}, template, {
|
||||
canvas_size_id: '',
|
||||
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
|
||||
canvas_size_width: currentWidth,
|
||||
canvas_size_height: currentHeight,
|
||||
background_color: String(template.background_color || '#111111').trim() || '#111111'
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = function renderTemplateFormPage(template, mode, message, canvasSizes, currentUser) {
|
||||
const isEdit = mode === 'edit';
|
||||
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
|
||||
const assetVersion = Date.now().toString(36);
|
||||
|
||||
return renderView(isEdit ? 'templates/edit' : 'templates/add', {
|
||||
title: isEdit ? 'Edit template' : 'Create Template',
|
||||
active: 'templates',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
template: current,
|
||||
inUse: Boolean(current.inUse),
|
||||
canvasSizes: canvasSizes || [],
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
});
|
||||
module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser) {
|
||||
return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false));
|
||||
};
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Template edit page renderer that reuses the add form.
|
||||
// Template edit page renderer.
|
||||
|
||||
const renderTemplateFormPage = require('./add');
|
||||
const { renderView } = require('../../../view');
|
||||
const { buildTemplateFormViewModel } = require('./form-view-model');
|
||||
|
||||
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
|
||||
return renderTemplateFormPage(template, 'edit', message, data && data.canvasSizes ? data.canvasSizes : [], currentUser);
|
||||
return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true));
|
||||
};
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user