Release v2.4.2
This commit is contained in:
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));
|
||||
};
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
// Shared template form view-model builder.
|
||||
|
||||
const { getRegionEditorScripts } = require('../../../lib/region-scripts');
|
||||
|
||||
function buildDefaultTemplate() {
|
||||
return {
|
||||
id: null,
|
||||
name: '',
|
||||
canvas_size_id: null,
|
||||
canvas_size_width: 1920,
|
||||
canvas_size_height: 1080,
|
||||
background_color: '#111111',
|
||||
background_image_path: '',
|
||||
region_usage: [],
|
||||
regions: []
|
||||
};
|
||||
}
|
||||
|
||||
function resolveTemplateCanvasSize(template, canvasSizes) {
|
||||
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
|
||||
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
|
||||
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
|
||||
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|
||||
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
|
||||
|
||||
if (matched) {
|
||||
return Object.assign({}, template, {
|
||||
canvas_size_id: matched.id,
|
||||
canvas_size_label: matched.name,
|
||||
canvas_size_width: matched.width,
|
||||
canvas_size_height: matched.height,
|
||||
background_color: String(template.background_color || '#111111').trim() || '#111111'
|
||||
});
|
||||
}
|
||||
|
||||
return Object.assign({}, template, {
|
||||
canvas_size_id: '',
|
||||
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
|
||||
canvas_size_width: currentWidth,
|
||||
canvas_size_height: currentHeight,
|
||||
background_color: String(template.background_color || '#111111').trim() || '#111111'
|
||||
});
|
||||
}
|
||||
|
||||
function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit) {
|
||||
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
|
||||
const assetVersion = Date.now().toString(36);
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit template' : 'Create Template',
|
||||
active: 'templates',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
template: current,
|
||||
inUse: Boolean(current.inUse),
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
deleteDisabled: !isEdit || Boolean(current.inUse),
|
||||
formAction: isEdit && current.id ? '/templates/' + current.id : '/templates',
|
||||
formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save' + (isEdit ? ' data-async-save-close-url="/templates"' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"',
|
||||
cancelUrl: '/templates',
|
||||
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
|
||||
canvasSizes: canvasSizes || [],
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildTemplateFormViewModel: buildTemplateFormViewModel
|
||||
};
|
||||
+2
-2
@@ -42,7 +42,7 @@ function loadTemplate(relativePath) {
|
||||
function renderView(viewName, context) {
|
||||
const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {});
|
||||
if (!viewContext.messageVariant) {
|
||||
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary');
|
||||
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'success');
|
||||
}
|
||||
const page = loadTemplate(`${viewName}.hbs`);
|
||||
const layout = loadTemplate(path.join('shared', 'layout.hbs'));
|
||||
@@ -53,7 +53,7 @@ function renderView(viewName, context) {
|
||||
function renderFragment(viewName, context) {
|
||||
const viewContext = Object.assign({ stylesheets: [], scripts: [], appVersion: appVersion }, context || {});
|
||||
if (!viewContext.messageVariant) {
|
||||
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'primary');
|
||||
viewContext.messageVariant = inferMessageVariant(viewContext.message, 'success');
|
||||
}
|
||||
const page = loadTemplate(`${viewName}.hbs`);
|
||||
const layout = loadTemplate(path.join('shared', 'frame-layout.hbs'));
|
||||
|
||||
@@ -11,8 +11,7 @@
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Display name</h3>
|
||||
</div>
|
||||
<form id="account-name-form" method="post" action="/account/name" data-async-save data-async-save-close-url="{{returnUrl}}">
|
||||
<input type="hidden" name="return_url" value="{{returnUrl}}" />
|
||||
<form id="account-name-form" method="post" action="/account/name" data-async-save>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<input id="account-name" type="text" name="name" class="form-control" value="{{name}}" autocomplete="name" required />
|
||||
@@ -20,7 +19,7 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Display name actions">
|
||||
{{{saveActionButtons formId="account-name-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<button type="submit" class="btn btn-success" form="account-name-form" name="save_action" value="save">Save</button>
|
||||
<a class="btn btn-warning" href="/dashboard" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -33,8 +32,7 @@
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Password</h3>
|
||||
</div>
|
||||
<form id="account-password-form" method="post" action="/account/password" data-async-save data-async-save-close-url="{{returnUrl}}">
|
||||
<input type="hidden" name="return_url" value="{{returnUrl}}" />
|
||||
<form id="account-password-form" method="post" action="/account/password" data-async-save>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="account-current-password" class="form-label">Current password</label>
|
||||
@@ -51,7 +49,7 @@
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Password actions">
|
||||
{{{saveActionButtons formId="account-password-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<button type="submit" class="btn btn-success" form="account-password-form" name="save_action" value="save">Save</button>
|
||||
<a class="btn btn-warning" href="/dashboard" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
<div class="login-logo text-center mb-3">
|
||||
<strong>Pulse</strong> Signage
|
||||
</div>
|
||||
<div class="card mx-auto" style="max-width: 26rem;">
|
||||
<div class="card-body p-4">
|
||||
<h1 class="h4 mb-3">Sign in</h1>
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add API source</h2>
|
||||
<p>Adjust the endpoint or refresh cadence for this API source.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">API source details</h3>
|
||||
</div>
|
||||
<form id="api-source-form" method="post" action="/data-sources/api-sources" data-async-save data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new">
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label for="api-source-name" class="form-label">Name</label>
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label for="api-source-url" class="form-label">API URL</label>
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<h4 class="api-source-section-heading">Access and parsing</h4>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-auth-method" class="form-label">Auth method</label>
|
||||
<select id="api-source-auth-method" name="auth_method" class="form-select" data-api-source-auth-method>
|
||||
<option value="none" {{#if (eq apiSource.authMethod 'none')}}selected{{/if}}>None</option>
|
||||
<option value="basic" {{#if (eq apiSource.authMethod 'basic')}}selected{{/if}}>Basic</option>
|
||||
<option value="bearer" {{#if (eq apiSource.authMethod 'bearer')}}selected{{/if}}>Bearer token</option>
|
||||
<option value="api_key_header" {{#if (eq apiSource.authMethod 'api_key_header')}}selected{{/if}}>API key header</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-8">
|
||||
<label for="api-source-items-path" class="form-label">Items path</label>
|
||||
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" placeholder="items, results.data, or another array path" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
<div class="col-12 col-md-8 offset-md-4">
|
||||
<div class="form-text">Optional. Use dot notation to point at the array this source should iterate through.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4" data-api-source-auth-details-section>
|
||||
<h4 class="api-source-section-heading">Authentication details</h4>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="basic" hidden>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="api-source-auth-username" class="form-label">Username</label>
|
||||
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" autocomplete="username" />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="api-source-auth-password" class="form-label">Password</label>
|
||||
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" autocomplete="current-password" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="bearer" hidden>
|
||||
<div class="col-12">
|
||||
<label for="api-source-auth-bearer-token" class="form-label">Bearer token</label>
|
||||
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-3" data-api-source-auth-panel="api_key_header" hidden>
|
||||
<div class="col-12 col-md-5">
|
||||
<label for="api-source-auth-header-name" class="form-label">Header name</label>
|
||||
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" placeholder="X-API-Key" />
|
||||
</div>
|
||||
<div class="col-12 col-md-7">
|
||||
<label for="api-source-auth-header-value" class="form-label">Header value</label>
|
||||
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="api-source-section-heading">Refresh</h4>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval" class="form-label">Update interval</label>
|
||||
<input id="api-source-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{apiSource.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="api-source-interval-unit" class="form-label">Unit</label>
|
||||
<select id="api-source-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq apiSource.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq apiSource.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="API source actions">
|
||||
{{{saveActionButtons formId="api-source-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/api-sources" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/assets/js/data-sources/api-source-form.js?v={{appVersion}}"></script>
|
||||
</form>
|
||||
</div>
|
||||
+26
-38
@@ -1,26 +1,24 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit API source - {{apiSource.name}}</h2>
|
||||
<p>Adjust the endpoint or refresh cadence and review the latest JSON response.</p>
|
||||
<h2>{{#if isEdit}}Edit API source - {{apiSource.name}}{{else}}Add API source{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Adjust the endpoint or refresh cadence and review the latest JSON response.{{else}}Adjust the endpoint or refresh cadence for this API source.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">API source details</h3>
|
||||
</div>
|
||||
<form id="api-source-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}" data-async-save data-async-save-refresh-target="#api-source-response-panel" data-async-save-refresh-state-url="/data-sources/api-sources/{{apiSource.id}}/state" data-async-save-close-url="/data-sources/api-sources" data-async-save-new-url="/data-sources/api-sources/new">
|
||||
<form id="api-source-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">API source details</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label for="api-source-name" class="form-label">Name</label>
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label for="api-source-url" class="form-label">API URL</label>
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-lg-6">
|
||||
<label for="api-source-name" class="form-label">Name</label>
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label for="api-source-url" class="form-label">API URL</label>
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
@@ -93,24 +91,15 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="API source actions">
|
||||
{{{saveActionButtons formId="api-source-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/api-sources" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
{{#if inUse}}
|
||||
<button type="submit" class="btn btn-outline-danger" form="delete-api-source-form" disabled>Delete</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-danger" form="delete-api-source-form">Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{{saveActionButtons formId="api-source-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card card-outline card-secondary mt-4" id="api-source-response-panel">
|
||||
<div class="card card-outline card-secondary mt-3" id="api-source-response-panel">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<h3 class="card-title mb-0">Latest response</h3>
|
||||
{{#if apiSource.lastResponseJson}}
|
||||
{{#if lastResponseJson}}
|
||||
<div class="btn-group btn-group-sm ms-auto" role="group" aria-label="JSON tree actions">
|
||||
<button type="button" class="btn btn-outline-secondary" data-json-toggle-collapse-all>
|
||||
<i class="bi bi-arrows-collapse me-1"></i>
|
||||
@@ -124,8 +113,8 @@
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if apiSource.lastPullError}}
|
||||
<div class="alert alert-warning">{{apiSource.lastPullError}}</div>
|
||||
{{#if lastPullError}}
|
||||
<div class="alert alert-warning">{{lastPullError}}</div>
|
||||
{{/if}}
|
||||
<dl class="row">
|
||||
<dt class="col-12 col-md-3">Last pulled</dt>
|
||||
@@ -143,10 +132,10 @@
|
||||
<dt class="col-12 col-md-3">Content type</dt>
|
||||
<dd class="col-12 col-md-9">{{#if apiSource.lastResponseContentType}}{{apiSource.lastResponseContentType}}{{else}}-{{/if}}</dd>
|
||||
</dl>
|
||||
{{#if apiSource.lastResponseJson}}
|
||||
{{#if lastResponseJson}}
|
||||
<div data-json-toggle-panel>
|
||||
<script type="application/json" data-json-toggle-source>{{json apiSource.lastResponseJson}}</script>
|
||||
<div class="mb-0 small bg-body-tertiary border rounded p-3 font-monospace" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{apiSource.lastResponseJson}}</div>
|
||||
<script type="application/json" data-json-toggle-source>{{json lastResponseJson}}</script>
|
||||
<div class="mb-0 small bg-body-tertiary border rounded p-3 font-monospace" style="white-space: pre-wrap; word-break: break-word;" data-json-toggle-output>{{lastResponseJson}}</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-secondary mb-0">No JSON response is stored yet.</div>
|
||||
@@ -154,5 +143,4 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-api-source-form" method="post" action="/data-sources/api-sources/{{apiSource.id}}/delete" data-confirm-message="Delete this API source?"></form>
|
||||
<script type="module" src="/assets/js/data-sources/api-source-form.js?v={{appVersion}}"></script>
|
||||
<script src="/assets/js/data-sources/api-source-form.js?v={{appVersion}}"></script>
|
||||
@@ -1,48 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add RSS feed</h2>
|
||||
<p>Capture a feed URL, refresh interval, and the number of items to store.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">RSS feed details</h3>
|
||||
</div>
|
||||
<form id="rss-feed-form" method="post" action="/data-sources/rss-feeds" data-async-save data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-name" class="form-label">Name</label>
|
||||
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-url" class="form-label">Feed URL</label>
|
||||
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" placeholder="https://example.com/feed.xml" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval" class="form-label">Update interval</label>
|
||||
<input id="rss-feed-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{rssFeed.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval-unit" class="form-label">Unit</label>
|
||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-item-limit" class="form-label">Items pulled</label>
|
||||
<input id="rss-feed-item-limit" name="item_limit" type="number" min="1" class="form-control" value="{{rssFeed.itemLimit}}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="RSS feed actions">
|
||||
{{{saveActionButtons formId="rss-feed-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/rss-feeds" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
@@ -1,189 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit RSS feed - {{rssFeed.name}}</h2>
|
||||
<p>Adjust the source URL, refresh interval, or item count.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">RSS feed details</h3>
|
||||
</div>
|
||||
<form id="rss-feed-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}" data-async-save data-async-save-refresh-target="#rss-feed-items-panel" data-async-save-refresh-state-url="/data-sources/rss-feeds/{{rssFeed.id}}/state" data-async-save-close-url="/data-sources/rss-feeds" data-async-save-new-url="/data-sources/rss-feeds/new">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-name" class="form-label">Name</label>
|
||||
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-url" class="form-label">Feed URL</label>
|
||||
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" placeholder="https://example.com/feed.xml" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval" class="form-label">Update interval</label>
|
||||
<input id="rss-feed-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{rssFeed.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval-unit" class="form-label">Unit</label>
|
||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-item-limit" class="form-label">Items pulled</label>
|
||||
<input id="rss-feed-item-limit" name="item_limit" type="number" min="1" class="form-control" value="{{rssFeed.itemLimit}}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="RSS feed actions">
|
||||
{{{saveActionButtons formId="rss-feed-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/rss-feeds" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
{{#if inUse}}
|
||||
<button type="submit" class="btn btn-outline-danger" form="delete-rss-feed-form" disabled>Delete</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-danger" form="delete-rss-feed-form">Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary mt-4" id="rss-feed-items-panel">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Pulled items</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if pullError}}
|
||||
<div class="alert alert-warning">{{pullError}}</div>
|
||||
{{/if}}
|
||||
{{#if pulledItems.length}}
|
||||
<div class="accordion rss-feed-items-accordion" id="rss-feed-items-accordion">
|
||||
{{#each pulledItems}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="rss-feed-item-heading-{{id}}-{{position}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#rss-feed-item-collapse-{{id}}-{{position}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="rss-feed-item-collapse-{{id}}-{{position}}">
|
||||
<span class="me-2">{{title}}</span>
|
||||
<span class="text-muted small">{{#if pubDate}}• {{pubDate}}{{/if}}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="rss-feed-item-collapse-{{id}}-{{position}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="rss-feed-item-heading-{{id}}-{{position}}" data-bs-parent="#rss-feed-items-accordion">
|
||||
<div class="accordion-body">
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-12 col-md-3">Title</dt>
|
||||
<dd class="col-12 col-md-9">{{title}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Link</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if link}}
|
||||
<a href="{{link}}" target="_blank" rel="noopener noreferrer">{{link}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Published</dt>
|
||||
<dd class="col-12 col-md-9">{{#if pubDate}}{{pubDate}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Author</dt>
|
||||
<dd class="col-12 col-md-9">{{#if itemJson.author}}{{itemJson.author}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">GUID</dt>
|
||||
<dd class="col-12 col-md-9">{{#if itemJson.guid}}{{itemJson.guid}}{{else}}-{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Comments</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if itemJson.comments}}
|
||||
<a href="{{itemJson.comments}}" target="_blank" rel="noopener noreferrer">{{itemJson.comments}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Description</dt>
|
||||
<dd class="col-12 col-md-9">{{#if description}}{{description}}{{else}}<span class="empty">-</span>{{/if}}</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Categories</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if itemJson.categories.length}}
|
||||
<ul class="list-group list-group-flush border rounded">
|
||||
{{#each itemJson.categories}}
|
||||
<li class="list-group-item d-flex justify-content-between align-items-start">
|
||||
<span>{{value}}</span>
|
||||
{{#if domain}}<span class="text-muted small ms-3">{{domain}}</span>{{/if}}
|
||||
</li>
|
||||
{{/each}}
|
||||
</ul>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Enclosure</dt>
|
||||
<dd class="col-12 col-md-9">
|
||||
{{#if itemJson.enclosure}}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-12 col-lg-4">URL</dt>
|
||||
<dd class="col-12 col-lg-8">
|
||||
{{#if itemJson.enclosure.url}}
|
||||
<a href="{{itemJson.enclosure.url}}" target="_blank" rel="noopener noreferrer">{{itemJson.enclosure.url}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
<dt class="col-12 col-lg-4">Length</dt>
|
||||
<dd class="col-12 col-lg-8">{{#if itemJson.enclosure.length}}{{itemJson.enclosure.length}}{{else}}-{{/if}}</dd>
|
||||
<dt class="col-12 col-lg-4">Type</dt>
|
||||
<dd class="col-12 col-lg-8">{{#if itemJson.enclosure.type}}{{itemJson.enclosure.type}}{{else}}-{{/if}}</dd>
|
||||
</dl>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Source</dt>
|
||||
<dd class="col-12 col-md-9 mb-0">
|
||||
{{#if itemJson.source}}
|
||||
<dl class="row mb-0">
|
||||
<dt class="col-12 col-lg-4">Title</dt>
|
||||
<dd class="col-12 col-lg-8">{{#if itemJson.source.title}}{{itemJson.source.title}}{{else}}-{{/if}}</dd>
|
||||
<dt class="col-12 col-lg-4">URL</dt>
|
||||
<dd class="col-12 col-lg-8">
|
||||
{{#if itemJson.source.url}}
|
||||
<a href="{{itemJson.source.url}}" target="_blank" rel="noopener noreferrer">{{itemJson.source.url}}</a>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
</dl>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
|
||||
<dt class="col-12 col-md-3">Raw item XML</dt>
|
||||
<dd class="col-12 col-md-9 mb-0">
|
||||
{{#if itemJson.rawXml}}
|
||||
<details>
|
||||
<summary class="small text-muted">Show raw XML</summary>
|
||||
<pre class="mb-0 mt-2 small bg-body-tertiary border rounded p-3 text-wrap">{{itemJson.rawXml}}</pre>
|
||||
</details>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-secondary mb-0">No feed items could be loaded.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-rss-feed-form" method="post" action="/data-sources/rss-feeds/{{rssFeed.id}}/delete" data-confirm-message="Delete this RSS feed?"></form>
|
||||
@@ -0,0 +1,141 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{#if isEdit}}Edit RSS feed - {{rssFeed.name}}{{else}}Add RSS feed{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Adjust the source URL, refresh interval, or item count.{{else}}Capture a feed URL, refresh interval, and the number of items to store.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="rss-feed-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">RSS feed details</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-name" class="form-label">Name</label>
|
||||
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" required />
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="rss-feed-url" class="form-label">Feed URL</label>
|
||||
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" placeholder="https://example.com/feed.xml" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval" class="form-label">Update interval</label>
|
||||
<input id="rss-feed-interval" name="update_interval_value" type="number" min="1" class="form-control" value="{{rssFeed.updateIntervalValue}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-interval-unit" class="form-label">Unit</label>
|
||||
<select id="rss-feed-interval-unit" name="update_interval_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq rssFeed.updateIntervalUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq rssFeed.updateIntervalUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-4">
|
||||
<label for="rss-feed-item-limit" class="form-label">Items pulled</label>
|
||||
<input id="rss-feed-item-limit" name="item_limit" type="number" min="1" class="form-control" value="{{rssFeed.itemLimit}}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{{saveActionButtons formId="rss-feed-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div class="card card-outline card-secondary admin-form-card mt-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Latest items</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="accordion" id="rss-feed-items-accordion">
|
||||
{{#if pullError}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="rss-feed-items-heading-error">
|
||||
<button class="accordion-button" type="button" data-bs-toggle="collapse" data-bs-target="#rss-feed-items-collapse-error" aria-expanded="true" aria-controls="rss-feed-items-collapse-error">
|
||||
Preview pulled feed items
|
||||
</button>
|
||||
</h2>
|
||||
<div id="rss-feed-items-collapse-error" class="accordion-collapse collapse show" aria-labelledby="rss-feed-items-heading-error" data-bs-parent="#rss-feed-items-accordion">
|
||||
<div class="accordion-body">
|
||||
<div class="alert alert-danger mb-0" role="alert">{{pullError}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else if pulledItems.length}}
|
||||
{{#each pulledItems}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="rss-feed-item-heading-{{@index}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#rss-feed-item-collapse-{{@index}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="rss-feed-item-collapse-{{@index}}">
|
||||
{{#if title}}{{title}}{{else}}Item {{@index}}{{/if}}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="rss-feed-item-collapse-{{@index}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="rss-feed-item-heading-{{@index}}" data-bs-parent="#rss-feed-items-accordion">
|
||||
<div class="accordion-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-striped align-middle mb-0">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap" style="width: 10rem;">Title</th>
|
||||
<td class="text-break">{{#if title}}{{title}}{{else}}-{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap">Link</th>
|
||||
<td class="text-break">
|
||||
{{#if link}}
|
||||
<a href="{{link}}" target="_blank" rel="noreferrer">{{link}}</a>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap">Published</th>
|
||||
<td class="text-break">{{#if pubDate}}{{pubDate}}{{else}}-{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap">Description</th>
|
||||
<td class="text-break">{{#if description}}{{description}}{{else}}-{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap">Author</th>
|
||||
<td class="text-break">{{#if author}}{{author}}{{else}}-{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap">Guid</th>
|
||||
<td class="text-break">{{#if guid}}{{guid}}{{else}}-{{/if}}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th scope="row" class="text-nowrap">Comments</th>
|
||||
<td class="text-break">
|
||||
{{#if comments}}
|
||||
<a href="{{comments}}" target="_blank" rel="noreferrer">{{comments}}</a>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="rss-feed-items-heading-empty">
|
||||
<button class="accordion-button collapsed" type="button" data-bs-toggle="collapse" data-bs-target="#rss-feed-items-collapse-empty" aria-expanded="false" aria-controls="rss-feed-items-collapse-empty">
|
||||
Preview pulled feed items
|
||||
</button>
|
||||
</h2>
|
||||
<div id="rss-feed-items-collapse-empty" class="accordion-collapse collapse" aria-labelledby="rss-feed-items-heading-empty" data-bs-parent="#rss-feed-items-accordion">
|
||||
<div class="accordion-body">
|
||||
<div class="text-muted">No feed items have been pulled yet.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
+19
-31
@@ -1,31 +1,31 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{#if isEdit}}Edit schedule group{{else}}Add schedule group{{/if}}</h2>
|
||||
<h2>{{#if isEdit}}Edit timetable group{{else}}Add timetable group{{/if}}</h2>
|
||||
<p>Define a group of events with their start and end times.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Schedule group details</h3>
|
||||
<h3 class="card-title">Timetable group details</h3>
|
||||
</div>
|
||||
<form id="schedule-group-form" method="post" action="{{#if isEdit}}/data-sources/schedules/{{scheduleGroup.id}}{{else}}/data-sources/schedules{{/if}}" data-async-save data-async-save-close-url="/data-sources/schedules" data-async-save-new-url="/data-sources/schedules/new">
|
||||
<form id="timetable-group-form" method="post" action="{{#if isEdit}}/data-sources/timetables/{{timetableGroup.id}}{{else}}/data-sources/timetables{{/if}}" data-async-save data-async-save-close-url="/data-sources/timetables"{{#unless isEdit}} data-async-save-new-redirect="response-url"{{/unless}} data-async-save-new-url="/data-sources/timetables/new">
|
||||
<div class="card-body d-grid gap-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="schedule-group-name" class="form-label">Name</label>
|
||||
<input id="schedule-group-name" name="name" class="form-control" value="{{scheduleGroup.name}}" required />
|
||||
<label for="timetable-group-name" class="form-label">Name</label>
|
||||
<input id="timetable-group-name" name="name" class="form-control" value="{{timetableGroup.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-8">
|
||||
<label for="schedule-group-short-description" class="form-label">Short description</label>
|
||||
<input id="schedule-group-short-description" name="short_description" class="form-control" value="{{scheduleGroup.shortDescription}}" placeholder="Morning events" />
|
||||
<label for="timetable-group-short-description" class="form-label">Short description</label>
|
||||
<input id="timetable-group-short-description" name="short_description" class="form-control" value="{{timetableGroup.shortDescription}}" placeholder="Morning events" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
|
||||
<h4 class="schedule-section-heading mb-0">Entries</h4>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-schedule-entry>Add entry</button>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-timetable-entry>Add entry</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle schedule-entries-table mb-0">
|
||||
@@ -38,9 +38,9 @@
|
||||
<th style="width: 6rem;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-schedule-entries-body>
|
||||
{{#each scheduleEntries}}
|
||||
<tr data-schedule-entry-row>
|
||||
<tbody data-timetable-entries-body>
|
||||
{{#each timetableEntries}}
|
||||
<tr data-timetable-entry-row>
|
||||
<td>
|
||||
<input type="hidden" name="entry_id[]" value="{{id}}" />
|
||||
<input type="text" name="entry_title[]" class="form-control" value="{{title}}" required />
|
||||
@@ -55,36 +55,24 @@
|
||||
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-schedule-entry>Remove</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="form-text mb-0">Add one or more rows. A schedule region can render upcoming or current entries from this group.</p>
|
||||
<p class="form-text mb-0">Add one or more rows. A timetable region can render upcoming or current entries from this group.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Schedule group actions">
|
||||
{{{saveActionButtons formId="schedule-group-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/data-sources/schedules" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
{{#if isEdit}}
|
||||
{{#if inUse}}
|
||||
<button type="submit" class="btn btn-outline-danger" form="delete-schedule-group-form" disabled>Delete</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-danger" form="delete-schedule-group-form">Delete</button>
|
||||
{{/if}}
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
{{{saveActionButtons formId="timetable-group-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<template id="schedule-entry-row-template">
|
||||
<tr data-schedule-entry-row>
|
||||
<template id="timetable-entry-row-template">
|
||||
<tr data-timetable-entry-row>
|
||||
<td>
|
||||
<input type="hidden" name="entry_id[]" value="" />
|
||||
<input type="text" name="entry_title[]" class="form-control" value="" required />
|
||||
@@ -99,13 +87,13 @@
|
||||
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-schedule-entry>Remove</button>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
{{#if isEdit}}
|
||||
<form id="delete-schedule-group-form" method="post" action="/data-sources/schedules/{{scheduleGroup.id}}/delete" data-confirm-message="Delete this schedule group?"></form>
|
||||
<form id="delete-timetable-group-form" method="post" action="/data-sources/timetables/{{timetableGroup.id}}/delete" data-confirm-message="Delete this timetable group?"></form>
|
||||
{{/if}}
|
||||
|
||||
<script type="module" src="/assets/js/data-sources/schedule-group-form.js?v={{assetVersion}}"></script>
|
||||
<script type="module" src="/assets/js/data-sources/timetable-group-form.js?v={{assetVersion}}"></script>
|
||||
+14
-14
@@ -1,7 +1,7 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Schedules</h2>
|
||||
<p>Store grouped events with start and end times so a schedule region can show what is coming up next.</p>
|
||||
<h2>Timetables</h2>
|
||||
<p>Store grouped events with start and end times so a timetable region can show what is coming up next.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,10 +11,10 @@
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search schedules" aria-label="Search schedules" data-table-search />
|
||||
<input type="search" class="form-control" placeholder="Search timetables" aria-label="Search timetables" data-table-search />
|
||||
</div>
|
||||
{{#if (hasPermission currentUser 'schedules.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/schedules/new">Add schedule group</a>
|
||||
{{#if (hasPermission currentUser 'timetables.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/data-sources/timetables/new">Add timetable group</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -30,8 +30,8 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if scheduleGroups.length}}
|
||||
{{#each scheduleGroups}}
|
||||
{{#if timetableGroups.length}}
|
||||
{{#each timetableGroups}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Description" class="text-break">{{short_description}}</td>
|
||||
@@ -44,13 +44,13 @@
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'schedules.update' 'schedules.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'timetables.update' 'timetables.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'schedules.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/data-sources/schedules/{{id}}/edit">Edit</a>
|
||||
{{#if (hasPermission ../currentUser 'timetables.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/data-sources/timetables/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'schedules.delete')}}
|
||||
<form class="inline-form" method="post" action="/data-sources/schedules/{{id}}/delete" data-confirm-message="Delete this schedule group?">
|
||||
{{#if (hasPermission ../currentUser 'timetables.delete')}}
|
||||
<form class="inline-form" method="post" action="/data-sources/timetables/{{id}}/delete" data-confirm-message="Delete this timetable group?">
|
||||
{{#if inUse}}
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
|
||||
{{else}}
|
||||
@@ -66,10 +66,10 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No schedule groups yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No timetable groups yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/data-sources/schedules" alwaysShow=true}}
|
||||
{{> table-pagination pagination=pagination basePath="/data-sources/timetables" alwaysShow=true}}
|
||||
</div>
|
||||
@@ -1,82 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Create role</h2>
|
||||
<p>Define a new role and choose its permissions before saving.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/rbac" id="role-create-form" data-async-save data-async-save-close-url="/rbac">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4 d-flex flex-column gap-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Role details</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="role-name" class="form-label">Role name</label>
|
||||
<input id="role-name" name="name" class="form-control" value="{{formValues.name}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="role-description" class="form-label">Description</label>
|
||||
<textarea id="role-description" name="description" class="form-control" rows="5">{{formValues.description}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8 d-flex flex-column gap-4">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Permissions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-body-secondary mb-3">Pick the permissions this role should have now.</p>
|
||||
{{#if permissionGroups.length}}
|
||||
<div class="accordion" id="role-create-permissions-accordion">
|
||||
{{#each permissionGroups}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="create-heading-{{id}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#create-collapse-{{id}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="create-collapse-{{id}}">
|
||||
<span>{{title}}</span>
|
||||
{{#if categoryName}}
|
||||
<span class="badge text-bg-secondary ms-2">{{categoryName}}</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="create-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="create-heading-{{id}}" data-bs-parent="#role-create-permissions-accordion">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3">
|
||||
{{#each permissions}}
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{actionLabel}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-warning mb-0">No permissions are available.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end mt-4">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
{{{saveActionButtons formId="role-create-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/rbac" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,15 +1,14 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit role - {{role.name}}</h2>
|
||||
<p>Choose which permissions and users this role receives.</p>
|
||||
<h2>{{#if isEdit}}Edit role - {{role.name}}{{else}}Create role{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Choose which permissions and users this role receives.{{else}}Define a new role and choose its permissions and users before saving.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form method="post" action="/rbac/{{role.id}}" id="role-edit-form" data-async-save data-async-save-close-url="/rbac">
|
||||
<input type="hidden" name="permissions_present" value="1" />
|
||||
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<input type="hidden" name="users_present" value="1" />
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-4 d-flex flex-column gap-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
@@ -17,23 +16,17 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="role-name-edit" class="form-label">Role Name</label>
|
||||
<input id="role-name-edit" name="name" class="form-control" value="{{role.name}}" required />
|
||||
<label for="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" class="form-label">Role name</label>
|
||||
<input id="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" name="name" class="form-control" value="{{#if isEdit}}{{role.name}}{{else}}{{formValues.name}}{{/if}}" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="role-description-edit" class="form-label">Description</label>
|
||||
<textarea id="role-description-edit" name="description" class="form-control" rows="5">{{role.description}}</textarea>
|
||||
<label for="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" class="form-label">Description</label>
|
||||
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5">{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end flex-wrap">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
{{{saveActionButtons formId="role-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/rbac" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
{{#if inUse}}
|
||||
<button type="submit" class="btn btn-outline-danger" form="role-delete-form" disabled>Delete</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-danger" form="role-delete-form">Delete</button>
|
||||
{{/if}}
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +49,7 @@
|
||||
<th style="width: 1%" scope="col" data-sortable="false">Select</th>
|
||||
<th scope="col" data-table-sort-key="user">Username</th>
|
||||
<th scope="col" data-table-sort-key="name">Name</th>
|
||||
<th scope="col" data-table-sort-key="roles">Roles</th>
|
||||
{{!-- <th scope="col" data-table-sort-key="roles">Roles</th> --}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -72,7 +65,7 @@
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
|
||||
<td data-label="Roles" class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
|
||||
{{!-- <td data-label="Roles" class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td> --}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
@@ -91,20 +84,20 @@
|
||||
<h3 class="card-title">Permissions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-body-secondary mb-3">Each section below is a resource. Pick the actions this role should have for that resource.</p>
|
||||
<p class="text-body-secondary mb-3">{{#if isEdit}}Each section below is a resource. Pick the actions this role should have for that resource.{{else}}Each section below is a resource. Pick the actions this role should have for that resource.{{/if}}</p>
|
||||
{{#if permissionGroups.length}}
|
||||
<div class="accordion" id="role-permissions-accordion">
|
||||
<div class="accordion" id="{{#if isEdit}}role-permissions-accordion{{else}}role-create-permissions-accordion{{/if}}">
|
||||
{{#each permissionGroups}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="heading-{{id}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#collapse-{{id}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="collapse-{{id}}">
|
||||
<h2 class="accordion-header" id="{{#if ../isEdit}}heading-{{id}}{{else}}create-heading-{{id}}{{/if}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}">
|
||||
<span>{{title}}</span>
|
||||
{{#if categoryName}}
|
||||
<span class="badge text-bg-secondary ms-2">{{categoryName}}</span>
|
||||
{{/if}}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="heading-{{id}}" data-bs-parent="#role-permissions-accordion">
|
||||
<div id="{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="{{#if ../isEdit}}heading-{{id}}{{else}}create-heading-{{id}}{{/if}}" data-bs-parent="#{{#if ../isEdit}}role-permissions-accordion{{else}}role-create-permissions-accordion{{/if}}">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3">
|
||||
{{#each permissions}}
|
||||
@@ -128,14 +121,7 @@
|
||||
<div class="alert alert-warning mb-0">No permissions are available.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Permission actions">
|
||||
{{{saveActionButtons formId="role-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/rbac/{{role.id}}/delete" id="role-delete-form" class="d-none" data-confirm-message="Delete {{role.name}}?"></form>
|
||||
</form>
|
||||
@@ -1,75 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add user</h2>
|
||||
<p>Create a new login for the web UI.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="user-form" method="post" action="/users">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">User details</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-username" class="form-label">Username</label>
|
||||
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-name" class="form-label">Name</label>
|
||||
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-password" class="form-label">Password</label>
|
||||
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-confirm-password" class="form-label">Confirm password</label>
|
||||
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="User actions">
|
||||
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{{#if roles.length}}
|
||||
{{#each roles}}
|
||||
<div class="col-4">
|
||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
@@ -1,102 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit user - {{user.name}}</h2>
|
||||
<p>Update the name or password for {{user.username}}.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-secondary admin-form-card mb-0">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Profile</h3>
|
||||
</div>
|
||||
<form id="user-profile-form" method="post" action="/users/{{user.id}}/username" data-async-save data-async-save-close-url="/users">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="edit-user-username" class="form-label">Username</label>
|
||||
<input id="edit-user-username" type="text" name="username" value="{{user.username}}" class="form-control" autocomplete="username" required disabled />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="edit-user-name" class="form-label">Name</label>
|
||||
<input id="edit-user-name" type="text" name="name" value="{{user.name}}" class="form-control" autocomplete="name" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Profile actions">
|
||||
{{{saveActionButtons formId="user-profile-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
{{#if inUse}}
|
||||
<button type="submit" class="btn btn-outline-danger" form="delete-user-form" disabled>Delete</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-danger" form="delete-user-form">Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-warning admin-form-card h-100 mb-0">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Reset password</h3>
|
||||
</div>
|
||||
<form id="user-password-form" method="post" action="/users/{{user.id}}/password" data-async-save data-async-save-close-url="/users">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="edit-user-password" class="form-label">New password</label>
|
||||
<input id="edit-user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="edit-user-confirm-password" class="form-label">Confirm password</label>
|
||||
<input id="edit-user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Password actions">
|
||||
{{{saveActionButtons formId="user-password-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-6">
|
||||
<div class="card card-outline card-secondary admin-form-card h-100 mb-0">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<form id="user-roles-form" method="post" action="/users/{{user.id}}/roles" data-async-save data-async-save-close-url="/users">
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{{#if roles.length}}
|
||||
{{#each roles}}
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-check card card-outline card-secondary p-3 h-100 mb-0">
|
||||
<input class="form-check-input" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label ms-2">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">Create at least one user before assigning this role.</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
{{{saveActionButtons formId="user-roles-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="delete-user-form" method="post" action="/users/{{user.id}}/delete" class="d-none" data-confirm-message="Delete {{user.username}}?"></form>
|
||||
@@ -0,0 +1,87 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{#if isEdit}}Edit user - {{user.name}}{{else}}Add user{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Update the user's login details and roles.{{else}}Create a new login for the web UI.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="{{formId}}" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{#if isEdit}}Profile{{else}}User details{{/if}}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" class="form-label">Username</label>
|
||||
<input id="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" type="text" name="username" class="form-control" autocomplete="username" value="{{#if isEdit}}{{user.username}}{{else}}{{formValues.username}}{{/if}}" {{#if isEdit}}disabled{{else}}required{{/if}} />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" class="form-label">Name</label>
|
||||
<input id="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" type="text" name="name" class="form-control" autocomplete="name" value="{{#if isEdit}}{{user.name}}{{else}}{{formValues.name}}{{/if}}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-warning admin-form-card mt-3">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{{#if isEdit}}Reset password{{else}}Password{{/if}}</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" class="form-label">Password</label>
|
||||
<input id="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" {{#unless isEdit}}required{{/unless}} />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" class="form-label">Confirm password</label>
|
||||
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" {{#unless isEdit}}required{{/unless}} />
|
||||
</div>
|
||||
</div>
|
||||
{{#if isEdit}}
|
||||
<div class="form-text mt-2">Leave both fields blank to keep the current password.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{{#if roles.length}}
|
||||
{{#each roles}}
|
||||
<div class="col-12 col-md-6 col-xl-4">
|
||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">{{#if isEdit}}Create at least one role before assigning this role.{{else}}Create at least one role before adding users.{{/if}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text mt-2">{{#if isEdit}}Select at least one role so the user can sign in with access.{{else}}Select at least one role so the new user can sign in with access.{{/if}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<script src="/assets/js/theme-init.js"></script>
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
<link rel="stylesheet" href="/assets/{{this}}" />
|
||||
|
||||
+110
-86
@@ -10,7 +10,7 @@
|
||||
<link rel="preload" href="/assets/adminlte/bootstrap-icons/fonts/bootstrap-icons.woff" as="font" type="font/woff" crossorigin="anonymous" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
<link rel="stylesheet" href="{{assetHref this}}" />
|
||||
@@ -25,11 +25,35 @@
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
{{else if authShell}}
|
||||
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
|
||||
{{#if message}}
|
||||
<div class="toast shadow-lg border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}" data-toast-created-at="">
|
||||
<div class="toast-header border-0 text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}warning{{/if}}">
|
||||
{{#if (eq messageVariant 'success')}}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'warning')}}
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'danger')}}
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'primary')}}
|
||||
<i class="bi bi-bell-fill me-2"></i>
|
||||
{{else}}
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
{{/if}}
|
||||
<strong class="me-auto">Pulse</strong>
|
||||
<small class="toast-time text-nowrap">just now</small>
|
||||
<button type="button" class="btn-close btn-close-white ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
|
||||
</div>
|
||||
<div class="toast-body">{{message}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
|
||||
<div class="login-box">
|
||||
{{{body}}}
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/js/toast.js"></script>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
{{else}}
|
||||
@@ -167,14 +191,6 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'announcements.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'announcements')}}active{{/if}}" href="/announcements">
|
||||
<i class="nav-icon bi bi-megaphone"></i>
|
||||
<p>Announcements</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'screens.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'screens')}}active{{/if}}" href="/screens">
|
||||
@@ -215,88 +231,96 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'announcements.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'announcements')}}active{{/if}}" href="/announcements">
|
||||
<i class="nav-icon bi bi-megaphone"></i>
|
||||
<p>Announcements</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}}
|
||||
<li class="nav-header">DATA SOURCES</li>
|
||||
{{#if (hasPermission currentUser 'rss-feeds.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'rss-feeds')}}active{{/if}}" href="/data-sources/rss-feeds">
|
||||
<i class="nav-icon bi bi-rss"></i>
|
||||
<p>RSS feeds</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'schedules.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'schedules')}}active{{/if}}" href="/data-sources/schedules">
|
||||
<i class="nav-icon bi bi-calendar-event"></i>
|
||||
<p>Schedules</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'api-sources.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'api-sources')}}active{{/if}}" href="/data-sources/api-sources">
|
||||
<i class="nav-icon bi bi-braces"></i>
|
||||
<p>API sources</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'rss-feeds.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'rss-feeds')}}active{{/if}}" href="/data-sources/rss-feeds">
|
||||
<i class="nav-icon bi bi-rss"></i>
|
||||
<p>RSS feeds</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'api-sources.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'api-sources')}}active{{/if}}" href="/data-sources/api-sources">
|
||||
<i class="nav-icon bi bi-braces"></i>
|
||||
<p>API sources</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'timetables.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'timetables')}}active{{/if}}" href="/data-sources/timetables">
|
||||
<i class="nav-icon bi bi-calendar-event"></i>
|
||||
<p>Timetables</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read' 'fonts.read')}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
{{#if (hasPermission currentUser 'users.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/users">
|
||||
<i class="nav-icon bi bi-people"></i>
|
||||
<p>Users</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'rbac.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'rbac')}}active{{/if}}" href="/rbac">
|
||||
<i class="nav-icon bi bi-shield-lock"></i>
|
||||
<p>Roles and permissions</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'fonts.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
|
||||
<i class="nav-icon bi bi-type"></i>
|
||||
<p>Fonts</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
|
||||
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
|
||||
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
|
||||
<i class="nav-icon bi bi-pen"></i>
|
||||
<p>
|
||||
Tasks
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
<ul class="nav nav-treeview">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
|
||||
<i class="nav-icon bi bi-list-ol"></i>
|
||||
<p>Task queue</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/tasks-scheduled">
|
||||
<i class="nav-icon bi bi-calendar-event"></i>
|
||||
<p>Scheduled tasks</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'users.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'users')}}active{{/if}}" href="/users">
|
||||
<i class="nav-icon bi bi-people"></i>
|
||||
<p>Users</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'rbac.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'rbac')}}active{{/if}}" href="/rbac">
|
||||
<i class="nav-icon bi bi-shield-lock"></i>
|
||||
<p>Roles and permissions</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'fonts.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'fonts')}}active{{/if}}" href="/settings/fonts">
|
||||
<i class="nav-icon bi bi-type"></i>
|
||||
<p>Fonts</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
|
||||
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
|
||||
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
|
||||
<i class="nav-icon bi bi-pen"></i>
|
||||
<p>
|
||||
Tasks
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
<ul class="nav nav-treeview">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
|
||||
<i class="nav-icon bi bi-list-ol"></i>
|
||||
<p>Task queue</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/tasks-scheduled">
|
||||
<i class="nav-icon bi bi-calendar-event"></i>
|
||||
<p>Scheduled tasks</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit announcement{{#if announcement.short_label}} - {{announcement.short_label}}{{/if}}</h2>
|
||||
<p>Update the message, screens, color, or expiry behavior.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="announcement-form" method="post" action="/announcements/{{announcement.id}}" data-async-save data-async-save-close-url="/announcements" data-async-save-new-url="/announcements/new">
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-outline card-primary admin-form-card h-100 mb-0">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Content</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<label for="announcement-short-label" class="form-label">Descriptor</label>
|
||||
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="255" placeholder="Power warning" />
|
||||
<div class="form-text">A short name for admin lists and page titles.</div>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="announcement-message" class="form-label">Message</label>
|
||||
<textarea id="announcement-message" name="message" class="form-control" rows="6" required>{{announcement.message}}</textarea>
|
||||
<div class="form-text">Use the full text that should appear on screens.</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="col-12 col-md-6 col-xl-12">
|
||||
<label for="announcement-type" class="form-label">Type</label>
|
||||
<select id="announcement-type" name="announcement_type" class="form-select">
|
||||
{{#each announcementTypes}}
|
||||
<option value="{{value}}" {{#if (eq ../announcement.announcement_type value)}}selected{{/if}}>{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-12">
|
||||
<label for="announcement-color" class="form-label">Color</label>
|
||||
<div class="row g-2 align-items-stretch">
|
||||
<div class="col">
|
||||
<select id="announcement-color" name="color_key" class="form-select">
|
||||
{{#each announcementColors}}
|
||||
<option value="{{value}}" {{#if (eq ../announcement.color_key value)}}selected{{/if}}>{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="announcement-color-preview" data-announcement-color-preview aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-12">
|
||||
<div class="form-label">Icon</div>
|
||||
<div class="announcement-icon-picker" data-announcement-icon-picker-shell>
|
||||
<select id="announcement-icon" name="icon_key" class="d-none" tabindex="-1" aria-hidden="true">
|
||||
{{#each announcementIcons}}
|
||||
<option value="{{value}}" {{#if (eq ../announcement.icon_key value)}}selected{{/if}}>{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<button type="button" class="announcement-icon-picker__toggle" data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu">
|
||||
<span class="announcement-icon-picker__toggle-icon" data-announcement-icon-picker-icon aria-hidden="true"></span>
|
||||
<span class="announcement-icon-picker__toggle-label" data-announcement-icon-picker-label>Select an icon</span>
|
||||
<i class="bi bi-chevron-down announcement-icon-picker__caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div id="announcement-icon-picker-menu" class="announcement-icon-picker__menu" data-announcement-icon-picker hidden>
|
||||
<div class="announcement-icon-picker__header">
|
||||
<span>Choose an icon</span>
|
||||
<button type="button" class="btn btn-sm btn-link text-decoration-none p-0" data-announcement-icon-picker-close>Close</button>
|
||||
</div>
|
||||
<div class="announcement-icon-picker__grid">
|
||||
{{#each announcementIcons}}
|
||||
<button type="button" class="announcement-icon-picker__option" data-announcement-icon-option data-icon-key="{{value}}" aria-pressed="false" title="{{label}}">
|
||||
<i class="bi bi-{{value}}" aria-hidden="true"></i>
|
||||
<span class="visually-hidden">{{label}}</span>
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-outline card-primary admin-form-card mb-3">
|
||||
<div class="card-footer d-flex flex-wrap align-items-center gap-2">
|
||||
<div class="btn-group" role="group" aria-label="Announcement play actions">
|
||||
<button type="submit" class="btn {{announcement.actionClassName}}" form="announcement-action-form"><i class="bi {{announcement.actionIcon}} me-1" aria-hidden="true"></i>{{announcement.actionLabel}}</button>
|
||||
</div>
|
||||
<div class="btn-group ms-auto" role="group" aria-label="Announcement save actions">
|
||||
{{{saveActionButtons formId="announcement-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/announcements" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
{{#if (hasPermission currentUser 'announcements.delete')}}
|
||||
{{#if announcement.id}}
|
||||
<button type="submit" class="btn {{#if announcement.canDelete}}btn-danger{{else}}btn-outline-danger{{/if}}" form="delete-announcement-form" {{#unless announcement.canDelete}}disabled title="{{announcement.deleteBlockedMessage}}"{{/unless}}>Delete</button>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-outline card-secondary admin-form-card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Screens</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-0">
|
||||
<div class="col-12 col-md-4 col-xl-12">
|
||||
<label for="announcement-duration-mode" class="form-label">Display mode</label>
|
||||
<select id="announcement-duration-mode" name="duration_mode" class="form-select">
|
||||
<option value="duration" {{#if (eq announcement.durationMode 'duration')}}selected{{/if}}>For a duration</option>
|
||||
<option value="until_disabled" {{#if (eq announcement.durationMode 'until_disabled')}}selected{{/if}}>Until disabled</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12" data-announcement-duration-value-fields {{#unless (eq announcement.durationMode 'duration')}}hidden{{/unless}}>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4 col-xl-6">
|
||||
<label for="announcement-duration-value" class="form-label">Duration value</label>
|
||||
<input id="announcement-duration-value" name="duration_value" type="number" min="1" class="form-control" value="{{announcement.durationValue}}" />
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-xl-6">
|
||||
<label for="announcement-duration-unit" class="form-label">Unit</label>
|
||||
<select id="announcement-duration-unit" name="duration_unit" class="form-select">
|
||||
<option value="seconds" {{#if (eq announcement.durationUnit 'seconds')}}selected{{/if}}>Seconds</option>
|
||||
<option value="minutes" {{#if (eq announcement.durationUnit 'minutes')}}selected{{/if}}>Minutes</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="announcement-screens" class="form-label">Target screens</label>
|
||||
<select id="announcement-screens" name="screen_ids[]" class="form-select" multiple size="8" required>
|
||||
{{#each announcementScreens}}
|
||||
<option value="{{id}}" {{#if isSelected}}selected{{/if}}>{{name}}{{#if slug}} ({{slug}}){{/if}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<div class="form-text">Select one or more screens for this announcement.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<script type="module" src="/assets/js/announcements/announcement-form.js?v={{appVersion}}"></script>
|
||||
</form>
|
||||
|
||||
<form id="announcement-action-form" method="post" action="{{announcement.actionPath}}" class="d-none" data-confirm-message="{{announcement.actionConfirmMessage}}" data-async-command></form>
|
||||
<form id="delete-announcement-form" method="post" action="/announcements/{{announcement.id}}/delete" class="d-none" data-confirm-message="Delete this announcement?"></form>
|
||||
+18
-12
@@ -1,11 +1,11 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Add announcement</h2>
|
||||
<p>Give the announcement a short description, then define the full message and target screens.</p>
|
||||
<h2>{{#if isEdit}}Edit announcement{{#if announcement.short_label}} - {{announcement.short_label}}{{/if}}{{else}}Add announcement{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Update the message, screens, color, or expiry behavior.{{else}}Give the announcement a short description, then define the full message and target screens.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<form id="announcement-form" method="post" action="/announcements" data-async-save data-async-save-close-url="/announcements" data-async-save-new-url="/announcements/new">
|
||||
<form id="announcement-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12 col-xl-7">
|
||||
<div class="card card-outline card-primary admin-form-card h-100 mb-0">
|
||||
@@ -14,13 +14,13 @@
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<label for="announcement-short-label" class="form-label">Short Description</label>
|
||||
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="255" placeholder="Announcement Short Name" />
|
||||
<label for="announcement-short-label" class="form-label">{{#if isEdit}}Descriptor{{else}}Short Description{{/if}}</label>
|
||||
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="255" placeholder="{{#if isEdit}}Power warning{{else}}Announcement Short Name{{/if}}" />
|
||||
<div class="form-text">A short name for admin lists and page titles.</div>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="announcement-message" class="form-label">Message</label>
|
||||
<textarea id="announcement-message" name="message" class="form-control" rows="6" required placeholder="Maintenance starts in 10 minutes.">{{announcement.message}}</textarea>
|
||||
<textarea id="announcement-message" name="message" class="form-control" rows="6" required placeholder="{{#if isEdit}}{{else}}Maintenance starts in 10 minutes.{{/if}}">{{announcement.message}}</textarea>
|
||||
<div class="form-text">Use the full text that should appear on screens.</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-0">
|
||||
@@ -82,11 +82,12 @@
|
||||
</div>
|
||||
<div class="col-12 col-xl-5">
|
||||
<div class="card card-outline card-primary admin-form-card mb-3">
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Announcement actions">
|
||||
{{{saveActionButtons formId="announcement-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/announcements" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
<button type="submit" class="btn btn-outline-danger" disabled>Delete</button>
|
||||
<div class="card-footer d-flex flex-wrap align-items-center">
|
||||
{{#if isEdit}}
|
||||
<button type="submit" class="btn {{announcement.actionClassName}}" form="{{actionFormId}}"><i class="bi {{announcement.actionIcon}} me-1" aria-hidden="true"></i>{{announcement.actionLabel}}</button>
|
||||
{{/if}}
|
||||
<div class="ms-auto d-flex flex-wrap align-items-center gap-2">
|
||||
{{{saveActionButtons formId="announcement-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled deleteTitle=announcement.deleteBlockedMessage showDelete=showDeleteAction showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -95,7 +96,7 @@
|
||||
<h3 class="card-title">Screens</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-4">
|
||||
<div class="row g-3 mb-0">
|
||||
<div class="col-12 col-md-4 col-xl-12">
|
||||
<label for="announcement-duration-mode" class="form-label">Display mode</label>
|
||||
<select id="announcement-duration-mode" name="duration_mode" class="form-select">
|
||||
@@ -132,3 +133,8 @@
|
||||
</div>
|
||||
<script type="module" src="/assets/js/announcements/announcement-form.js?v={{appVersion}}"></script>
|
||||
</form>
|
||||
|
||||
{{#if isEdit}}
|
||||
<form id="announcement-action-form" method="post" action="{{announcement.actionPath}}" class="d-none" data-confirm-message="{{announcement.actionConfirmMessage}}" data-async-command></form>
|
||||
<form id="delete-announcement-form" method="post" action="/announcements/{{announcement.id}}/delete" class="d-none" data-confirm-message="Delete this announcement?"></form>
|
||||
{{/if}}
|
||||
@@ -1,44 +0,0 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Edit canvas - {{canvasSize.name}}</h2>
|
||||
<p>Update the stored canvas dimensions.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Canvas size details</h3>
|
||||
</div>
|
||||
<form id="canvas-size-form" method="post" action="/canvas-sizes/{{canvasSize.id}}" data-async-save data-async-save-close-url="/canvas-sizes" data-async-save-new-url="/canvas-sizes/new">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="canvas-size-name" class="form-label">Name</label>
|
||||
<input id="canvas-size-name" name="name" class="form-control" value="{{canvasSize.name}}" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="canvas-size-width" class="form-label">Width</label>
|
||||
<input id="canvas-size-width" name="width" type="number" min="1" class="form-control" value="{{canvasSize.width}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="canvas-size-height" class="form-label">Height</label>
|
||||
<input id="canvas-size-height" name="height" type="number" min="1" class="form-control" value="{{canvasSize.height}}" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Canvas size actions">
|
||||
{{{saveActionButtons formId="canvas-size-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||
<a class="btn btn-warning" href="/canvas-sizes" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
{{#if inUse}}
|
||||
<button type="submit" class="btn btn-outline-danger" form="delete-canvas-size-form" disabled>Delete</button>
|
||||
{{else}}
|
||||
<button type="submit" class="btn btn-danger" form="delete-canvas-size-form">Delete</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<form id="delete-canvas-size-form" method="post" action="/canvas-sizes/{{canvasSize.id}}/delete" data-confirm-message="Delete this canvas size?"></form>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user