Release v2.5.6
This commit is contained in:
+16
@@ -7,6 +7,21 @@ const PASSWORD_KEY_LENGTH = 32;
|
||||
const PASSWORD_DIGEST = 'sha256';
|
||||
const SESSION_BYTES = 32;
|
||||
|
||||
function validatePasswordStrength(password) {
|
||||
const value = String(password || '');
|
||||
const hasLowercase = /[a-z]/.test(value);
|
||||
const hasUppercase = /[A-Z]/.test(value);
|
||||
const hasNumber = /[0-9]/.test(value);
|
||||
const hasSymbol = /[^A-Za-z0-9]/.test(value);
|
||||
const categoryCount = [hasLowercase, hasUppercase, hasNumber, hasSymbol].filter(Boolean).length;
|
||||
|
||||
if (value.length < 10 || categoryCount < 3) {
|
||||
return 'Password must be at least 10 characters and include 3 of: uppercase, lowercase, number, and symbol.';
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function hashPassword(password, salt) {
|
||||
const safePassword = String(password || '');
|
||||
const safeSalt = salt || crypto.randomBytes(16).toString('hex');
|
||||
@@ -40,6 +55,7 @@ function hashSessionToken(token) {
|
||||
module.exports = {
|
||||
hashPassword,
|
||||
verifyPassword,
|
||||
validatePasswordStrength,
|
||||
createSessionToken,
|
||||
hashSessionToken
|
||||
};
|
||||
@@ -37,6 +37,8 @@ module.exports = {
|
||||
slugify: data.slugify,
|
||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||
parseJsonSafe: data.parseJsonSafe,
|
||||
validateMaxLength: data.validateMaxLength,
|
||||
truncateToMaxLength: data.truncateToMaxLength,
|
||||
fetchAdminData: data.fetchAdminData,
|
||||
fetchPlaylistsPage: data.fetchPlaylistsPage,
|
||||
fetchSlidesPage: data.fetchSlidesPage,
|
||||
@@ -91,6 +93,7 @@ module.exports = {
|
||||
fetchCanvasSizesData: data.fetchCanvasSizesData,
|
||||
fetchCanvasSizeById: data.fetchCanvasSizeById,
|
||||
buildCanvasSizePayload: data.buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION: data.MAX_CANVAS_SIZE_DIMENSION,
|
||||
buildSlidePayload: data.buildSlidePayload,
|
||||
extractTemplateRegions: data.extractTemplateRegions,
|
||||
buildTemplatePayload: data.buildTemplatePayload,
|
||||
|
||||
+1
-1
@@ -94,7 +94,7 @@ async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDi
|
||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||
ORDER BY s.id DESC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM c_slides',
|
||||
searchColumns: ['s.title', 'st.name', 's.content_json'],
|
||||
searchColumns: ['s.title', 'st.name'],
|
||||
searchTerm: searchTerm,
|
||||
sortColumns: {
|
||||
title: 's.title',
|
||||
|
||||
@@ -6,6 +6,9 @@ const {
|
||||
DEFAULT_ANNOUNCEMENT_ICON,
|
||||
normalizeAnnouncementIcon: normalizeAnnouncementIconFromConfig
|
||||
} = require('./announcement-icons');
|
||||
const { validateMaxLength } = require('./utils');
|
||||
|
||||
const SHORT_LABEL_MAX_LENGTH = 255;
|
||||
|
||||
const ANNOUNCEMENT_TYPES = ['lower-third', 'fullscreen', 'top-banner'];
|
||||
const ANNOUNCEMENT_COLORS = ['primary', 'secondary', 'success', 'info', 'warning', 'danger', 'dark', 'light'];
|
||||
@@ -57,7 +60,7 @@ function normalizeAnnouncementScreenIds(value) {
|
||||
|
||||
function buildAnnouncementPayload(input) {
|
||||
const message = normalizeAnnouncementMessage(input && input.message);
|
||||
const shortLabel = normalizeAnnouncementShortLabel(input && input.short_label);
|
||||
const shortLabel = validateMaxLength(normalizeAnnouncementShortLabel(input && input.short_label), SHORT_LABEL_MAX_LENGTH, 'Announcement short description');
|
||||
const announcementType = normalizeAnnouncementType(input && input.announcement_type);
|
||||
const colorKey = normalizeAnnouncementColor(input && input.color_key);
|
||||
const iconKey = normalizeAnnouncementIconFromConfig(input && input.icon_key);
|
||||
|
||||
+14
-9
@@ -2,7 +2,12 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
const AUTH_MAX_LENGTH = 255;
|
||||
const ITEMS_PATH_MAX_LENGTH = 255;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -171,15 +176,15 @@ function buildApiSourcePayload(req, existingApiSource) {
|
||||
return fallbackValue;
|
||||
}
|
||||
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const apiUrl = String(req.body.api_url || req.body.apiUrl || fallback.api_url || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'API source name');
|
||||
const apiUrl = validateMaxLength(req.body.api_url || req.body.apiUrl || fallback.api_url || '', URL_MAX_LENGTH, 'API source URL');
|
||||
const authMethod = normalizeAuthMethod(readBodyValue('auth_method', readBodyValue('authMethod', fallback.auth_method || 'none')));
|
||||
const authUsername = String(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '').trim();
|
||||
const authPassword = String(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '');
|
||||
const authBearerToken = String(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '').trim();
|
||||
const authHeaderName = String(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key').trim() || 'X-API-Key';
|
||||
const authHeaderValue = String(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '').trim();
|
||||
const itemsPath = String(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '').trim();
|
||||
const authUsername = validateMaxLength(readBodyValue('auth_username', readBodyValue('authUsername', fallback.auth_username || '')) || '', AUTH_MAX_LENGTH, 'API source username');
|
||||
const authPassword = validateMaxLength(readBodyValue('auth_password', readBodyValue('authPassword', fallback.auth_password || '')) || '', AUTH_MAX_LENGTH, 'API source password');
|
||||
const authBearerToken = validateMaxLength(readBodyValue('auth_bearer_token', readBodyValue('authBearerToken', fallback.auth_bearer_token || '')) || '', AUTH_MAX_LENGTH, 'API source bearer token');
|
||||
const authHeaderName = validateMaxLength(readBodyValue('auth_header_name', readBodyValue('authHeaderName', fallback.auth_header_name || 'X-API-Key')) || 'X-API-Key', AUTH_MAX_LENGTH, 'API source header name') || 'X-API-Key';
|
||||
const authHeaderValue = validateMaxLength(readBodyValue('auth_header_value', readBodyValue('authHeaderValue', fallback.auth_header_value || '')) || '', AUTH_MAX_LENGTH, 'API source header value');
|
||||
const itemsPath = validateMaxLength(readBodyValue('items_path', readBodyValue('itemsPath', fallback.items_path || '')) || '', ITEMS_PATH_MAX_LENGTH, 'API source items path');
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Canvas size data access and pagination helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const MAX_CANVAS_SIZE_DIMENSION = 16384;
|
||||
|
||||
async function fetchCanvasSizesData(pool) {
|
||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM c_canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||
@@ -33,10 +34,31 @@ async function fetchCanvasSizeById(pool, id) {
|
||||
return rows[0] || null;
|
||||
}
|
||||
|
||||
function readCanvasDimension(rawValue, fallbackValue, fieldName) {
|
||||
const sourceValue = rawValue !== undefined && rawValue !== null && String(rawValue).trim() !== ''
|
||||
? rawValue
|
||||
: fallbackValue;
|
||||
const numericValue = Number(sourceValue);
|
||||
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
const error = new Error('Canvas size ' + fieldName + ' must be a number.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (numericValue > MAX_CANVAS_SIZE_DIMENSION) {
|
||||
const error = new Error('Canvas size dimensions must be 16384 or less.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
|
||||
return Math.max(1, numericValue);
|
||||
}
|
||||
|
||||
function buildCanvasSizePayload(req, existingCanvasSize) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0));
|
||||
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0));
|
||||
const width = readCanvasDimension(req.body.width, existingCanvasSize && existingCanvasSize.width, 'width');
|
||||
const height = readCanvasDimension(req.body.height, existingCanvasSize && existingCanvasSize.height, 'height');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Canvas size name is required.');
|
||||
@@ -55,5 +77,6 @@ module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizesPage,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload
|
||||
buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION
|
||||
};
|
||||
|
||||
+5
-2
@@ -9,14 +9,16 @@ const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSo
|
||||
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData, fetchScreenPlayerUrls, fetchScreenPlayerRecord } = require('./screens');
|
||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload, MAX_CANVAS_SIZE_DIMENSION } = require('./canvas-sizes');
|
||||
const { fetchSlideById, buildSlidePayload } = require('./slides');
|
||||
const { parseJsonSafe, fetchDuplicateName } = require('./utils');
|
||||
const { parseJsonSafe, fetchDuplicateName, validateMaxLength, truncateToMaxLength } = require('./utils');
|
||||
|
||||
module.exports = {
|
||||
slugify,
|
||||
uniqueScreenSlug,
|
||||
parseJsonSafe,
|
||||
validateMaxLength,
|
||||
truncateToMaxLength,
|
||||
fetchAdminData,
|
||||
fetchPlaylistsPage,
|
||||
fetchSlidesPage,
|
||||
@@ -66,6 +68,7 @@ module.exports = {
|
||||
fetchCanvasSizesData,
|
||||
fetchCanvasSizeById,
|
||||
buildCanvasSizePayload,
|
||||
MAX_CANVAS_SIZE_DIMENSION,
|
||||
buildSlidePayload,
|
||||
extractTemplateRegions,
|
||||
buildTemplatePayload,
|
||||
|
||||
+552
-17
@@ -1,29 +1,561 @@
|
||||
const QRCode = require('qrcode');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const QRCodeStyling = require(path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js'));
|
||||
const puppeteer = require('puppeteer-core');
|
||||
const chromiumModule = require('@sparticuz/chromium');
|
||||
const QR_PNG_WIDTH = 2048;
|
||||
const QR_STYLING_SCRIPT_PATH = path.join(__dirname, '..', 'web', 'public', 'vendor', 'qr-code-styling', 'qr-code-styling.js');
|
||||
const SYSTEM_CHROMIUM_PATHS = [
|
||||
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||
process.env.CHROMIUM_PATH,
|
||||
'/usr/bin/chromium',
|
||||
'/usr/bin/chromium-browser',
|
||||
'/usr/local/bin/chromium',
|
||||
'/snap/bin/chromium'
|
||||
].filter(Boolean);
|
||||
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||
? chromiumModule
|
||||
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||
? chromiumModule.default
|
||||
: chromiumModule;
|
||||
let qrBrowserPromise = null;
|
||||
|
||||
async function createQrCodeSvg(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
if (!text) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return QRCode.toString(text, {
|
||||
type: 'svg',
|
||||
margin: 1,
|
||||
errorCorrectionLevel: 'M'
|
||||
function escapeXml(value) {
|
||||
return String(value === undefined || value === null ? '' : value).replace(/[&<>"']/g, function (character) {
|
||||
return {
|
||||
'&': '&',
|
||||
'<': '<',
|
||||
'>': '>',
|
||||
'"': '"',
|
||||
"'": '''
|
||||
}[character];
|
||||
});
|
||||
}
|
||||
|
||||
async function buildQrCodeContent(value) {
|
||||
const text = String(value === undefined || value === null ? '' : value).trim();
|
||||
function serializeNode(node) {
|
||||
if (!node) {
|
||||
return '';
|
||||
}
|
||||
if (node.nodeType === 3) {
|
||||
return escapeXml(node.textContent || '');
|
||||
}
|
||||
|
||||
const attributeEntries = Object.keys(node.attributes || {}).map(function (name) {
|
||||
return name + '="' + escapeXml(node.attributes[name]) + '"';
|
||||
});
|
||||
if (node.tagName === 'svg' && node.namespaceURI === 'http://www.w3.org/2000/svg' && !Object.prototype.hasOwnProperty.call(node.attributes || {}, 'xmlns')) {
|
||||
attributeEntries.unshift('xmlns="http://www.w3.org/2000/svg"');
|
||||
}
|
||||
const attributes = attributeEntries.join(' ');
|
||||
const children = (node.childNodes || []).map(serializeNode).join('') + escapeXml(node.textContent || '');
|
||||
return '<' + node.tagName + (attributes ? ' ' + attributes : '') + '>' + children + '</' + node.tagName + '>';
|
||||
}
|
||||
|
||||
function createDomNode(tagName, namespaceUri) {
|
||||
return {
|
||||
tagName: tagName,
|
||||
namespaceURI: namespaceUri || null,
|
||||
attributes: {},
|
||||
childNodes: [],
|
||||
parentNode: null,
|
||||
nodeType: 1,
|
||||
textContent: '',
|
||||
style: {},
|
||||
setAttribute: function (name, value) {
|
||||
this.attributes[name] = String(value);
|
||||
},
|
||||
appendChild: function (child) {
|
||||
if (child) {
|
||||
this.childNodes.push(child);
|
||||
child.parentNode = this;
|
||||
}
|
||||
return child;
|
||||
},
|
||||
removeChild: function (child) {
|
||||
this.childNodes = this.childNodes.filter(function (node) { return node !== child; });
|
||||
return child;
|
||||
},
|
||||
get firstChild() {
|
||||
return this.childNodes[0] || null;
|
||||
},
|
||||
get outerHTML() {
|
||||
return serializeNode(this);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function createCanvasContext() {
|
||||
return {
|
||||
fillStyle: '#000000',
|
||||
strokeStyle: '#000000',
|
||||
lineWidth: 1,
|
||||
beginPath: function () {},
|
||||
closePath: function () {},
|
||||
clearRect: function () {},
|
||||
fillRect: function () {},
|
||||
strokeRect: function () {},
|
||||
moveTo: function () {},
|
||||
lineTo: function () {},
|
||||
arc: function () {},
|
||||
rect: function () {},
|
||||
fill: function () {},
|
||||
stroke: function () {},
|
||||
save: function () {},
|
||||
restore: function () {},
|
||||
translate: function () {},
|
||||
rotate: function () {},
|
||||
scale: function () {},
|
||||
setTransform: function () {},
|
||||
transform: function () {},
|
||||
drawImage: function () {},
|
||||
measureText: function (text) {
|
||||
return { width: String(text === undefined || text === null ? '' : text).length * 8 };
|
||||
},
|
||||
createLinearGradient: function () {
|
||||
return { addColorStop: function () {} };
|
||||
},
|
||||
createRadialGradient: function () {
|
||||
return { addColorStop: function () {} };
|
||||
},
|
||||
createPattern: function () {
|
||||
return null;
|
||||
},
|
||||
getImageData: function () {
|
||||
return { data: [] };
|
||||
},
|
||||
putImageData: function () {},
|
||||
clip: function () {}
|
||||
};
|
||||
}
|
||||
|
||||
function createCanvasNode() {
|
||||
const node = createDomNode('canvas', 'http://www.w3.org/1999/xhtml');
|
||||
node.width = 0;
|
||||
node.height = 0;
|
||||
node.getContext = function () {
|
||||
return createCanvasContext();
|
||||
};
|
||||
node.toDataURL = function () {
|
||||
return '';
|
||||
};
|
||||
node.toBlob = function (callback) {
|
||||
if (typeof callback === 'function') {
|
||||
callback(null);
|
||||
}
|
||||
};
|
||||
return node;
|
||||
}
|
||||
|
||||
function createQrStylingWindow() {
|
||||
const document = {
|
||||
createElement: function (tagName) {
|
||||
if (String(tagName || '').toLowerCase() === 'canvas') {
|
||||
return createCanvasNode();
|
||||
}
|
||||
return createDomNode(tagName, 'http://www.w3.org/1999/xhtml');
|
||||
},
|
||||
createElementNS: function (namespaceUri, tagName) {
|
||||
return createDomNode(tagName, namespaceUri);
|
||||
},
|
||||
createTextNode: function (text) {
|
||||
return {
|
||||
nodeType: 3,
|
||||
textContent: String(text === undefined || text === null ? '' : text),
|
||||
parentNode: null
|
||||
};
|
||||
},
|
||||
body: createDomNode('body', 'http://www.w3.org/1999/xhtml'),
|
||||
head: createDomNode('head', 'http://www.w3.org/1999/xhtml')
|
||||
};
|
||||
|
||||
const window = {
|
||||
document: document,
|
||||
navigator: { userAgent: 'node' },
|
||||
URL: {
|
||||
createObjectURL: function () { return ''; },
|
||||
revokeObjectURL: function () {}
|
||||
}
|
||||
};
|
||||
|
||||
window.window = window;
|
||||
window.self = window;
|
||||
window.Image = class {
|
||||
constructor() {
|
||||
this.onload = null;
|
||||
this.onerror = null;
|
||||
this.width = 1;
|
||||
this.height = 1;
|
||||
this.naturalWidth = 1;
|
||||
this.naturalHeight = 1;
|
||||
this._src = '';
|
||||
}
|
||||
|
||||
set src(value) {
|
||||
this._src = String(value === undefined || value === null ? '' : value);
|
||||
if (typeof this.onload === 'function') {
|
||||
setTimeout(() => this.onload(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
get src() {
|
||||
return this._src;
|
||||
}
|
||||
};
|
||||
window.HTMLImageElement = window.Image;
|
||||
|
||||
window.XMLSerializer = class {
|
||||
serializeToString(node) {
|
||||
return serializeNode(node);
|
||||
}
|
||||
};
|
||||
|
||||
return { window: window, document: document };
|
||||
}
|
||||
|
||||
async function renderStyledQrRawData(options, format) {
|
||||
const previousWindow = global.window;
|
||||
const previousDocument = global.document;
|
||||
const previousXMLSerializer = global.XMLSerializer;
|
||||
const dom = createQrStylingWindow();
|
||||
|
||||
global.window = dom.window;
|
||||
global.document = dom.document;
|
||||
global.XMLSerializer = dom.window.XMLSerializer;
|
||||
|
||||
try {
|
||||
const qrCode = new QRCodeStyling(options);
|
||||
const blob = await qrCode.getRawData(format);
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob === 'string') {
|
||||
return blob;
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return binary;
|
||||
}
|
||||
|
||||
return '';
|
||||
} finally {
|
||||
global.window = previousWindow;
|
||||
global.document = previousDocument;
|
||||
global.XMLSerializer = previousXMLSerializer;
|
||||
}
|
||||
}
|
||||
|
||||
function svgToDataUrl(svg) {
|
||||
const markup = String(svg === undefined || svg === null ? '' : svg).trim();
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return 'data:image/svg+xml;charset=utf-8,' + encodeURIComponent(markup);
|
||||
}
|
||||
|
||||
function normalizeQrStyleColor(value, fallback) {
|
||||
const raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function normalizeQrStyleNumber(value, fallback, min, max) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
let next = parsed;
|
||||
if (typeof min === 'number') {
|
||||
next = Math.max(min, next);
|
||||
}
|
||||
if (typeof max === 'number') {
|
||||
next = Math.min(max, next);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
function buildQrStylingOptions(source) {
|
||||
const text = String(source && source.value === undefined ? '' : source && source.value === null ? '' : source.value || '').trim();
|
||||
const qrStyle = source && typeof source === 'object' ? source : {};
|
||||
const dotsGradient = qrStyle.qr_dots_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_dots_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_dots_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_dots_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const cornersSquareGradient = qrStyle.qr_corners_square_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_corners_square_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_square_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_square_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const cornersDotGradient = qrStyle.qr_corners_dot_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_corners_dot_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_corners_dot_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_corners_dot_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
const backgroundGradient = qrStyle.qr_background_color_mode === 'gradient' ? {
|
||||
type: String(qrStyle.qr_background_gradient_type || 'linear').trim() || 'linear',
|
||||
rotation: normalizeQrStyleNumber(qrStyle.qr_background_gradient_rotation, 0, undefined, undefined),
|
||||
colorStops: [
|
||||
{ offset: 0, color: normalizeQrStyleColor(qrStyle.qr_background_gradient_color_1, normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff')) },
|
||||
{ offset: 1, color: normalizeQrStyleColor(qrStyle.qr_background_gradient_color_2, '#ffffff') }
|
||||
]
|
||||
} : null;
|
||||
|
||||
return {
|
||||
width: QR_PNG_WIDTH,
|
||||
height: QR_PNG_WIDTH,
|
||||
type: 'canvas',
|
||||
data: text,
|
||||
margin: normalizeQrStyleNumber(qrStyle.qr_margin, 10, 0, undefined),
|
||||
qrOptions: {},
|
||||
dotsOptions: {
|
||||
type: String(qrStyle.qr_dots_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_dots_color, '#000000'),
|
||||
gradient: dotsGradient || undefined
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
type: String(qrStyle.qr_corners_square_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_corners_square_color, '#000000'),
|
||||
gradient: cornersSquareGradient || undefined
|
||||
},
|
||||
cornersDotOptions: {
|
||||
type: String(qrStyle.qr_corners_dot_type || 'square').trim() || 'square',
|
||||
color: normalizeQrStyleColor(qrStyle.qr_corners_dot_color, '#000000'),
|
||||
gradient: cornersDotGradient || undefined
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: qrStyle.qr_background_transparent ? 'transparent' : normalizeQrStyleColor(qrStyle.qr_background_color, '#ffffff'),
|
||||
gradient: backgroundGradient || undefined
|
||||
},
|
||||
image: String(qrStyle.qr_image || '').trim() || undefined,
|
||||
imageOptions: {
|
||||
hideBackgroundDots: Boolean(qrStyle.qr_image_hide_background_dots),
|
||||
imageSize: normalizeQrStyleNumber(qrStyle.qr_image_size, 0.4, 0, 1),
|
||||
margin: normalizeQrStyleNumber(qrStyle.qr_image_margin, 5, 0, undefined)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function getQrBrowser() {
|
||||
if (qrBrowserPromise) {
|
||||
return qrBrowserPromise;
|
||||
}
|
||||
|
||||
qrBrowserPromise = (async function () {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
}) || '';
|
||||
const usingSystemChromium = Boolean(executablePath);
|
||||
|
||||
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||
executablePath = await chromium.executablePath();
|
||||
}
|
||||
|
||||
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||
throw new Error('Chromium executable was not found.');
|
||||
}
|
||||
|
||||
return puppeteer.launch({
|
||||
args: usingSystemChromium
|
||||
? [
|
||||
'--no-sandbox',
|
||||
'--disable-setuid-sandbox',
|
||||
'--disable-dev-shm-usage',
|
||||
'--disable-gpu'
|
||||
]
|
||||
: puppeteer.defaultArgs({
|
||||
args: chromium && chromium.args ? chromium.args : [],
|
||||
headless: 'shell'
|
||||
}),
|
||||
defaultViewport: usingSystemChromium
|
||||
? { width: QR_PNG_WIDTH, height: QR_PNG_WIDTH, deviceScaleFactor: 1 }
|
||||
: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||
executablePath: executablePath,
|
||||
headless: usingSystemChromium ? true : 'shell'
|
||||
});
|
||||
})();
|
||||
|
||||
return qrBrowserPromise;
|
||||
}
|
||||
|
||||
async function blobToDataUrl(blob) {
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob === 'string') {
|
||||
return blob;
|
||||
}
|
||||
|
||||
if (typeof blob.arrayBuffer === 'function') {
|
||||
const buffer = await blob.arrayBuffer();
|
||||
const bytes = new Uint8Array(buffer);
|
||||
let binary = '';
|
||||
for (let index = 0; index < bytes.length; index += 1) {
|
||||
binary += String.fromCharCode(bytes[index]);
|
||||
}
|
||||
return 'data:' + String(blob.type || 'image/png') + ';base64,' + Buffer.from(binary, 'binary').toString('base64');
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
async function createStyledQrCodeDataUrl(value) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const options = buildQrStylingOptions(source);
|
||||
if (!options.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return svgToDataUrl(await createStyledQrCodeSvg(options.data));
|
||||
}
|
||||
|
||||
async function createStyledQrCodeSvg(value) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const options = buildQrStylingOptions(source);
|
||||
if (!options.data) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return renderStyledQrRawData(options, 'svg');
|
||||
}
|
||||
|
||||
async function createQrCodeDataUrlPlain(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function createQrCodeSvg(value) {
|
||||
return createStyledQrCodeSvg(value);
|
||||
}
|
||||
|
||||
async function createQrCodeDataUrl(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function buildQrCodeContent(value, options) {
|
||||
const source = value && typeof value === 'object' ? value : { value: value };
|
||||
const forcePreviewRefresh = Boolean(options && options.forcePreviewRefresh);
|
||||
const text = String(source.value === undefined || source.value === null ? '' : source.value).trim();
|
||||
const hasBooleanField = function (key) {
|
||||
return Object.prototype.hasOwnProperty.call(source, key);
|
||||
};
|
||||
const margin = Number(source.qr_margin);
|
||||
const normalizeHex = function (value) {
|
||||
const raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || undefined;
|
||||
};
|
||||
const normalizeNumber = function (value, min, max, round) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
let next = parsed;
|
||||
if (round) {
|
||||
next = Math.round(next);
|
||||
}
|
||||
if (typeof min === 'number') {
|
||||
next = Math.max(min, next);
|
||||
}
|
||||
if (typeof max === 'number') {
|
||||
next = Math.min(max, next);
|
||||
}
|
||||
return next;
|
||||
};
|
||||
const style = {
|
||||
qr_margin: Number.isFinite(margin) && margin >= 0 ? Math.round(margin) : undefined,
|
||||
qr_border_radius: Number.isFinite(Number(source.qr_border_radius)) ? Math.max(0, Math.round(Number(source.qr_border_radius))) : undefined,
|
||||
qr_background_color: String(source.qr_background_color === undefined || source.qr_background_color === null ? '' : source.qr_background_color).trim() || undefined,
|
||||
qr_background_transparent: hasBooleanField('qr_background_transparent') ? Boolean(source.qr_background_transparent) : undefined,
|
||||
qr_background_color_mode: String(source.qr_background_color_mode === undefined || source.qr_background_color_mode === null ? '' : source.qr_background_color_mode).trim() || undefined,
|
||||
qr_background_gradient_type: String(source.qr_background_gradient_type === undefined || source.qr_background_gradient_type === null ? '' : source.qr_background_gradient_type).trim() || undefined,
|
||||
qr_background_gradient_rotation: normalizeNumber(source.qr_background_gradient_rotation),
|
||||
qr_background_gradient_color_1: normalizeHex(source.qr_background_gradient_color_1),
|
||||
qr_background_gradient_color_2: normalizeHex(source.qr_background_gradient_color_2),
|
||||
qr_dots_color: String(source.qr_dots_color === undefined || source.qr_dots_color === null ? '' : source.qr_dots_color).trim() || undefined,
|
||||
qr_dots_type: String(source.qr_dots_type === undefined || source.qr_dots_type === null ? '' : source.qr_dots_type).trim() || undefined,
|
||||
qr_dots_color_mode: String(source.qr_dots_color_mode === undefined || source.qr_dots_color_mode === null ? '' : source.qr_dots_color_mode).trim() || undefined,
|
||||
qr_dots_gradient_type: String(source.qr_dots_gradient_type === undefined || source.qr_dots_gradient_type === null ? '' : source.qr_dots_gradient_type).trim() || undefined,
|
||||
qr_dots_gradient_rotation: normalizeNumber(source.qr_dots_gradient_rotation),
|
||||
qr_dots_gradient_color_1: normalizeHex(source.qr_dots_gradient_color_1),
|
||||
qr_dots_gradient_color_2: normalizeHex(source.qr_dots_gradient_color_2),
|
||||
qr_corners_square_color: String(source.qr_corners_square_color === undefined || source.qr_corners_square_color === null ? '' : source.qr_corners_square_color).trim() || undefined,
|
||||
qr_corners_square_type: String(source.qr_corners_square_type === undefined || source.qr_corners_square_type === null ? '' : source.qr_corners_square_type).trim() || undefined,
|
||||
qr_corners_square_color_mode: String(source.qr_corners_square_color_mode === undefined || source.qr_corners_square_color_mode === null ? '' : source.qr_corners_square_color_mode).trim() || undefined,
|
||||
qr_corners_square_gradient_type: String(source.qr_corners_square_gradient_type === undefined || source.qr_corners_square_gradient_type === null ? '' : source.qr_corners_square_gradient_type).trim() || undefined,
|
||||
qr_corners_square_gradient_rotation: normalizeNumber(source.qr_corners_square_gradient_rotation),
|
||||
qr_corners_square_gradient_color_1: normalizeHex(source.qr_corners_square_gradient_color_1),
|
||||
qr_corners_square_gradient_color_2: normalizeHex(source.qr_corners_square_gradient_color_2),
|
||||
qr_corners_dot_color: String(source.qr_corners_dot_color === undefined || source.qr_corners_dot_color === null ? '' : source.qr_corners_dot_color).trim() || undefined,
|
||||
qr_corners_dot_type: String(source.qr_corners_dot_type === undefined || source.qr_corners_dot_type === null ? '' : source.qr_corners_dot_type).trim() || undefined,
|
||||
qr_corners_dot_color_mode: String(source.qr_corners_dot_color_mode === undefined || source.qr_corners_dot_color_mode === null ? '' : source.qr_corners_dot_color_mode).trim() || undefined,
|
||||
qr_corners_dot_gradient_type: String(source.qr_corners_dot_gradient_type === undefined || source.qr_corners_dot_gradient_type === null ? '' : source.qr_corners_dot_gradient_type).trim() || undefined,
|
||||
qr_corners_dot_gradient_rotation: normalizeNumber(source.qr_corners_dot_gradient_rotation),
|
||||
qr_corners_dot_gradient_color_1: normalizeHex(source.qr_corners_dot_gradient_color_1),
|
||||
qr_corners_dot_gradient_color_2: normalizeHex(source.qr_corners_dot_gradient_color_2),
|
||||
qr_image: String(source.qr_image === undefined || source.qr_image === null ? '' : source.qr_image).trim() || undefined,
|
||||
qr_image_size: Number.isFinite(Number(source.qr_image_size)) ? Math.max(0, Math.min(1, Number(source.qr_image_size))) : undefined,
|
||||
qr_image_margin: Number.isFinite(Number(source.qr_image_margin)) ? Math.max(0, Math.round(Number(source.qr_image_margin))) : undefined,
|
||||
qr_image_hide_background_dots: hasBooleanField('qr_image_hide_background_dots') ? Boolean(source.qr_image_hide_background_dots) : undefined
|
||||
};
|
||||
const content = {
|
||||
type: 'qr-code',
|
||||
value: text
|
||||
};
|
||||
|
||||
if (text) {
|
||||
const svg = await createQrCodeSvg(text);
|
||||
if (svg) {
|
||||
content.qr_svg = svg;
|
||||
Object.keys(style).forEach((key) => {
|
||||
if (style[key] !== undefined) {
|
||||
content[key] = style[key];
|
||||
}
|
||||
});
|
||||
|
||||
content.qr_background_use_gradient = content.qr_background_gradient_type && content.qr_background_gradient_type !== 'none';
|
||||
content.qr_dots_use_gradient = content.qr_dots_gradient_type && content.qr_dots_gradient_type !== 'none';
|
||||
content.qr_corners_square_use_gradient = content.qr_corners_square_gradient_type && content.qr_corners_square_gradient_type !== 'none';
|
||||
content.qr_corners_dot_use_gradient = content.qr_corners_dot_gradient_type && content.qr_corners_dot_gradient_type !== 'none';
|
||||
|
||||
const svg = String(source.qr_svg === undefined || source.qr_svg === null ? '' : source.qr_svg).trim();
|
||||
if (svg) {
|
||||
content.qr_svg = svg;
|
||||
}
|
||||
|
||||
const preview = forcePreviewRefresh ? '' : String(source.qr_preview === undefined || source.qr_preview === null ? '' : source.qr_preview).trim();
|
||||
if (preview) {
|
||||
content.qr_preview = preview;
|
||||
}
|
||||
|
||||
if (text && !content.qr_preview) {
|
||||
const generatedSvg = await createQrCodeSvg(text);
|
||||
if (generatedSvg) {
|
||||
content.qr_svg = generatedSvg;
|
||||
}
|
||||
const generatedDataUrl = await createStyledQrCodeDataUrl(content);
|
||||
if (generatedDataUrl) {
|
||||
content.qr_preview = generatedDataUrl;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,5 +564,8 @@ async function buildQrCodeContent(value) {
|
||||
|
||||
module.exports = {
|
||||
createQrCodeSvg,
|
||||
createStyledQrCodeSvg,
|
||||
createStyledQrCodeDataUrl,
|
||||
createQrCodeDataUrl,
|
||||
buildQrCodeContent
|
||||
};
|
||||
@@ -2,7 +2,10 @@
|
||||
|
||||
const http = require('http');
|
||||
const https = require('https');
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const URL_MAX_LENGTH = 1024;
|
||||
|
||||
function normalizeUpdateIntervalUnit(value) {
|
||||
const unit = String(value || '').trim().toLowerCase();
|
||||
@@ -255,8 +258,8 @@ async function replaceRssFeedItems(connection, rssFeedId, items) {
|
||||
|
||||
function buildRssFeedPayload(req, existingRssFeed) {
|
||||
const fallback = existingRssFeed || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const feedUrl = String(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'RSS feed name');
|
||||
const feedUrl = validateMaxLength(req.body.feed_url || req.body.feedUrl || fallback.feed_url || '', URL_MAX_LENGTH, 'RSS feed URL');
|
||||
const updateIntervalValue = Math.max(1, Number(req.body.update_interval_value || req.body.updateIntervalValue || fallback.update_interval_value || 60));
|
||||
const updateIntervalUnit = normalizeUpdateIntervalUnit(req.body.update_interval_unit || req.body.updateIntervalUnit || fallback.update_interval_unit || 'minutes');
|
||||
const itemLimit = Math.max(1, Number(req.body.item_limit || req.body.itemLimit || fallback.item_limit || 1));
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
// Timetable group and entry data access helpers.
|
||||
|
||||
const { fetchPagedRows } = require('./utils');
|
||||
const { fetchPagedRows, validateMaxLength } = require('./utils');
|
||||
|
||||
const NAME_MAX_LENGTH = 255;
|
||||
const DESCRIPTION_MAX_LENGTH = 255;
|
||||
|
||||
function normalizeDisplayMode(value) {
|
||||
const mode = String(value || 'upcoming').trim().toLowerCase();
|
||||
@@ -95,8 +98,8 @@ async function fetchTimetableEntriesByGroupId(pool, timetableGroupId) {
|
||||
|
||||
function buildTimetableGroupPayload(req, existingTimetableGroup) {
|
||||
const fallback = existingTimetableGroup || {};
|
||||
const name = String(req.body.name || fallback.name || '').trim();
|
||||
const shortDescription = String(req.body.short_description || req.body.shortDescription || fallback.short_description || '').trim();
|
||||
const name = validateMaxLength(req.body.name || fallback.name || '', NAME_MAX_LENGTH, 'Timetable group name');
|
||||
const shortDescription = validateMaxLength(req.body.short_description || req.body.shortDescription || fallback.short_description || '', DESCRIPTION_MAX_LENGTH, 'Timetable group description');
|
||||
|
||||
if (!name) {
|
||||
const error = new Error('Timetable group name is required.');
|
||||
|
||||
+144
-9
@@ -1,7 +1,9 @@
|
||||
// Slide data access helpers, including rich-text normalization and payload building.
|
||||
|
||||
const { fetchTemplateById } = require('./templates');
|
||||
const { parseJsonSafe } = require('./utils');
|
||||
const { parseJsonSafe, validateMaxLength } = require('./utils');
|
||||
|
||||
const TITLE_MAX_LENGTH = 255;
|
||||
const { buildQrCodeContent } = require('./qr-code');
|
||||
|
||||
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
|
||||
@@ -112,9 +114,130 @@ function getTextRegionStyle(body, region, existingContent) {
|
||||
};
|
||||
}
|
||||
|
||||
function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
function getQrRegionStyle(body, region, existingContent) {
|
||||
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const styleKeys = [
|
||||
'qr_margin',
|
||||
'qr_background_color',
|
||||
'qr_background_transparent',
|
||||
'qr_background_color_mode',
|
||||
'qr_background_gradient_type',
|
||||
'qr_background_gradient_rotation',
|
||||
'qr_background_gradient_color_1',
|
||||
'qr_background_gradient_color_2',
|
||||
'qr_dots_color',
|
||||
'qr_dots_type',
|
||||
'qr_dots_color_mode',
|
||||
'qr_dots_gradient_type',
|
||||
'qr_dots_gradient_rotation',
|
||||
'qr_dots_gradient_color_1',
|
||||
'qr_dots_gradient_color_2',
|
||||
'qr_corners_square_color',
|
||||
'qr_corners_square_type',
|
||||
'qr_corners_square_color_mode',
|
||||
'qr_corners_square_gradient_type',
|
||||
'qr_corners_square_gradient_rotation',
|
||||
'qr_corners_square_gradient_color_1',
|
||||
'qr_corners_square_gradient_color_2',
|
||||
'qr_corners_dot_color',
|
||||
'qr_corners_dot_type',
|
||||
'qr_corners_dot_color_mode',
|
||||
'qr_corners_dot_gradient_type',
|
||||
'qr_corners_dot_gradient_rotation',
|
||||
'qr_corners_dot_gradient_color_1',
|
||||
'qr_corners_dot_gradient_color_2',
|
||||
'qr_image',
|
||||
'qr_image_size',
|
||||
'qr_image_margin',
|
||||
'qr_image_hide_background_dots',
|
||||
'qr_border_radius'
|
||||
];
|
||||
const style = {};
|
||||
|
||||
styleKeys.forEach((key) => {
|
||||
const fieldNames = [`region_${key}_${region.id}`, `${key}_${region.id}`];
|
||||
let fieldName = fieldNames[0];
|
||||
let hasSubmittedValue = false;
|
||||
let submitted;
|
||||
|
||||
for (let index = 0; index < fieldNames.length; index += 1) {
|
||||
const candidate = fieldNames[index];
|
||||
if (Object.prototype.hasOwnProperty.call(body || {}, candidate)) {
|
||||
fieldName = candidate;
|
||||
hasSubmittedValue = true;
|
||||
submitted = body[candidate];
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
const value = String(submitted || '').trim();
|
||||
|
||||
if (key === 'qr_background_transparent' || key === 'qr_image_hide_background_dots') {
|
||||
style[key] = hasSubmittedValue;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_background_color_mode' || key === 'qr_dots_color_mode' || key === 'qr_corners_square_color_mode' || key === 'qr_corners_dot_color_mode' || key === 'qr_background_gradient_type' || key === 'qr_dots_gradient_type' || key === 'qr_corners_square_gradient_type' || key === 'qr_corners_dot_gradient_type') {
|
||||
style[key] = value === 'none' ? 'none' : value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_image') {
|
||||
style[key] = value;
|
||||
return;
|
||||
}
|
||||
|
||||
if (submitted === undefined) {
|
||||
if (existing[key] !== undefined && existing[key] !== null && String(existing[key]).trim() !== '') {
|
||||
style[key] = existing[key];
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (value !== '') {
|
||||
if (key === 'qr_margin') {
|
||||
const parsedMargin = Number(value);
|
||||
if (Number.isFinite(parsedMargin)) {
|
||||
style[key] = Math.max(0, Math.round(parsedMargin));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_image_size') {
|
||||
const parsedSize = Number(value);
|
||||
if (Number.isFinite(parsedSize)) {
|
||||
style[key] = Math.max(0, Math.min(1, parsedSize));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (key === 'qr_image_margin' || key === 'qr_border_radius') {
|
||||
const parsedImageMargin = Number(value);
|
||||
if (Number.isFinite(parsedImageMargin)) {
|
||||
style[key] = Math.max(0, Math.round(parsedImageMargin));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
style[key] = value;
|
||||
}
|
||||
});
|
||||
|
||||
return style;
|
||||
}
|
||||
|
||||
async function getRssFeedItemCount(pool, feedId) {
|
||||
const [rows] = await pool.query(
|
||||
'SELECT COUNT(*) AS count FROM i_rss_feed_items WHERE rss_feed_id = ?',
|
||||
[feedId]
|
||||
);
|
||||
|
||||
return Number(rows && rows[0] && rows[0].count) || 0;
|
||||
}
|
||||
|
||||
async function buildTemplateContent(pool, template, body, filesByField, existingContent) {
|
||||
const content = {};
|
||||
template.regions.forEach((region) => {
|
||||
for (const region of template.regions) {
|
||||
if (region.region_type === 'image') {
|
||||
const uploaded = filesByField[`region_image_${region.id}`];
|
||||
const existing = body[`existing_region_image_${region.id}`];
|
||||
@@ -144,12 +267,22 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
value: submitted === undefined ? current : String(submitted || '').trim()
|
||||
};
|
||||
} else if (region.region_type === 'qr-code') {
|
||||
const uploadedImage = filesByField[`region_qr_image_${region.id}`];
|
||||
const submitted = body[`region_qr_code_${region.id}`];
|
||||
const submittedSvg = body[`region_qr_svg_${region.id}`];
|
||||
const submittedPreview = body[`region_qr_preview_${region.id}`];
|
||||
const existingImage = body[`existing_region_qr_image_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
|
||||
const nextValue = submitted === undefined ? String(current.value !== undefined ? current.value : current.qr_code || '').trim() : String(submitted || '').trim();
|
||||
const qrStyle = getQrRegionStyle(body, region, existingContent);
|
||||
delete qrStyle.qr_image;
|
||||
content[region.region_key] = {
|
||||
type: 'qr-code',
|
||||
value: nextValue
|
||||
value: nextValue,
|
||||
qr_svg: submittedSvg === undefined ? String(current.qr_svg || '').trim() : String(submittedSvg || '').trim(),
|
||||
qr_preview: submittedPreview === undefined ? String(current.qr_preview || '').trim() : String(submittedPreview || '').trim(),
|
||||
qr_image: uploadedImage ? `/media/uploads/${uploadedImage.filename}` : String(existingImage === undefined ? String(current.qr_image || '').trim() : String(existingImage || '').trim()),
|
||||
...qrStyle
|
||||
};
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
const submitted = body[`region_rtmp_${region.id}`];
|
||||
@@ -182,11 +315,13 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
const feedId = body[`region_rss_feed_id_${region.id}`];
|
||||
const itemNumber = body[`region_rss_item_number_${region.id}`];
|
||||
const parsedItemNumber = Math.max(1, Number(itemNumber || current.item_number || 1));
|
||||
const normalizedFeedId = feedId === undefined || feedId === null || feedId === '' ? Number(current.feed_id || 0) : Number(feedId);
|
||||
const itemCount = normalizedFeedId > 0 ? Math.max(1, await getRssFeedItemCount(pool, normalizedFeedId) || 1) : 1;
|
||||
content[region.region_key] = {
|
||||
type: 'rss',
|
||||
value: stripEditorOnlyMarkup(normalizeEditorMarkup(submitted === undefined ? String(current.value || '') : String(submitted || ''))),
|
||||
feed_id: feedId === undefined || feedId === null || feedId === '' ? (current.feed_id || null) : Number(feedId),
|
||||
item_number: Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1,
|
||||
item_number: Math.min(itemCount, Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber : 1),
|
||||
variable_name: 'item',
|
||||
font_family: style.font_family,
|
||||
font_size: style.font_size,
|
||||
@@ -258,12 +393,12 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
font_color: style.font_color
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
return content;
|
||||
}
|
||||
|
||||
async function buildSlidePayload(pool, req, existingSlide) {
|
||||
const title = String(req.body.title || '').trim();
|
||||
const title = validateMaxLength(req.body.title || '', TITLE_MAX_LENGTH, 'Slide title');
|
||||
const templateId = req.body.template_id ? Number(req.body.template_id) : null;
|
||||
const filesByField = getFilesByField(req.files || []);
|
||||
const template = templateId ? await fetchTemplateById(pool, templateId) : null;
|
||||
@@ -282,7 +417,7 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
}
|
||||
|
||||
if (template) {
|
||||
const content = buildTemplateContent(template, req.body, filesByField, existingContent);
|
||||
const content = await buildTemplateContent(pool, template, req.body, filesByField, existingContent);
|
||||
await Promise.all(Object.keys(content).map(async function (regionKey) {
|
||||
const region = template.regions.find(function (item) {
|
||||
return String(item.region_key || '').trim() === regionKey;
|
||||
@@ -291,7 +426,7 @@ async function buildSlidePayload(pool, req, existingSlide) {
|
||||
return;
|
||||
}
|
||||
|
||||
content[regionKey] = await buildQrCodeContent(content[regionKey].value);
|
||||
content[regionKey] = await buildQrCodeContent(content[regionKey]);
|
||||
}));
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
// Template data access helpers and region normalization logic.
|
||||
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
|
||||
const animationPresets = require('../web/public/js/templates/animation-presets');
|
||||
|
||||
const TEMPLATE_NAME_MAX_LENGTH = 255;
|
||||
const REGION_NAME_MAX_LENGTH = 255;
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
const raw = String(value || '').trim();
|
||||
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
|
||||
@@ -137,10 +140,11 @@ function extractTemplateRegions(body) {
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed.map((region) => {
|
||||
const regionType = normalizeTemplateRegionType(region.region_type);
|
||||
const regionName = validateMaxLength(region.region_name || region.region_key || region.label || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
||||
return {
|
||||
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
|
||||
region_key: regionName,
|
||||
region_type: regionType,
|
||||
label: String(region.region_name || region.label || region.region_key || '').trim(),
|
||||
label: regionName,
|
||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
||||
x: Number(region.x || 0),
|
||||
@@ -167,7 +171,7 @@ function extractTemplateRegions(body) {
|
||||
const regions = [];
|
||||
|
||||
for (let i = 0; i < keys.length; i += 1) {
|
||||
const name = String(names[i] || keys[i] || labels[i] || '').trim();
|
||||
const name = validateMaxLength(names[i] || keys[i] || labels[i] || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
||||
const rawType = String(types[i] || 'text').trim();
|
||||
const regionType = normalizeTemplateRegionType(rawType);
|
||||
if (!name) {
|
||||
@@ -236,7 +240,7 @@ function getFilesByField(files) {
|
||||
}
|
||||
|
||||
async function buildTemplatePayload(pool, req, existingTemplate) {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = validateMaxLength(req.body.name || '', TEMPLATE_NAME_MAX_LENGTH, 'Template name');
|
||||
const canvasSizeId = req.body.canvas_size_id ? Number(req.body.canvas_size_id) : null;
|
||||
let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
|
||||
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
|
||||
|
||||
+100
-1
@@ -24,6 +24,26 @@ function readFormArray(body, key) {
|
||||
return [body[key]];
|
||||
}
|
||||
|
||||
function validateMaxLength(value, maxLength, fieldName) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (Number.isFinite(limit) && limit > 0 && text.length > limit) {
|
||||
const error = new Error(fieldName + ' must be ' + limit + ' characters or fewer.');
|
||||
error.statusCode = 400;
|
||||
throw error;
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function truncateToMaxLength(value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit <= 0 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
function normalizePageNumber(value) {
|
||||
const pageNumber = Math.floor(Number(value) || 1);
|
||||
return Math.max(1, pageNumber);
|
||||
@@ -201,6 +221,75 @@ function findTopLevelWhereIndex(sql) {
|
||||
return lastWhereIndex;
|
||||
}
|
||||
|
||||
function findTopLevelGroupByIndex(sql) {
|
||||
const text = String(sql || '');
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inBacktick = false;
|
||||
let lastGroupByIndex = -1;
|
||||
|
||||
for (let index = 0; index < text.length; index += 1) {
|
||||
const character = text[index];
|
||||
const previousCharacter = index > 0 ? text[index - 1] : '';
|
||||
|
||||
if (inSingleQuote) {
|
||||
if (character === '\'' && previousCharacter !== '\\') {
|
||||
inSingleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inDoubleQuote) {
|
||||
if (character === '"' && previousCharacter !== '\\') {
|
||||
inDoubleQuote = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (inBacktick) {
|
||||
if (character === '`') {
|
||||
inBacktick = false;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '\'') {
|
||||
inSingleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '"') {
|
||||
inDoubleQuote = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '`') {
|
||||
inBacktick = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === '(') {
|
||||
depth += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (character === ')' && depth > 0) {
|
||||
depth -= 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (depth === 0 && /[gG]/.test(character)) {
|
||||
const remaining = text.slice(index);
|
||||
if (/^group\s+by\b/i.test(remaining)) {
|
||||
lastGroupByIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastGroupByIndex;
|
||||
}
|
||||
|
||||
function buildSearchFilter(searchColumns, searchTerm) {
|
||||
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
|
||||
return String(column || '').trim();
|
||||
@@ -238,17 +327,24 @@ async function fetchPagedRows(pool, options) {
|
||||
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||
}
|
||||
const orderByIndex = findTopLevelOrderByIndex(selectSql);
|
||||
const groupByIndex = findTopLevelGroupByIndex(selectSql);
|
||||
let baseSelectSql = selectSql;
|
||||
let orderBySql = '';
|
||||
let groupBySql = '';
|
||||
|
||||
if (orderByIndex >= 0) {
|
||||
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
|
||||
orderBySql = selectSql.slice(orderByIndex).trim();
|
||||
}
|
||||
|
||||
if (groupByIndex >= 0 && (orderByIndex < 0 || groupByIndex < orderByIndex)) {
|
||||
baseSelectSql = selectSql.slice(0, groupByIndex).trim();
|
||||
groupBySql = selectSql.slice(groupByIndex, orderByIndex >= 0 ? orderByIndex : selectSql.length).trim();
|
||||
}
|
||||
|
||||
const hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
|
||||
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}${groupBySql ? ' ' + groupBySql : ''}`;
|
||||
const countQuery = searchFilter.clause
|
||||
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
|
||||
: countSql;
|
||||
@@ -294,10 +390,13 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
|
||||
module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray,
|
||||
validateMaxLength,
|
||||
truncateToMaxLength,
|
||||
normalizePageNumber,
|
||||
normalizeSortDirection,
|
||||
buildSortOrderClause,
|
||||
findTopLevelOrderByIndex,
|
||||
findTopLevelGroupByIndex,
|
||||
buildSearchFilter,
|
||||
fetchPagedRows,
|
||||
fetchDuplicateName
|
||||
|
||||
+1
-3
@@ -46,8 +46,7 @@ async function start() {
|
||||
playerRuntime: playerRuntime,
|
||||
onboardingStore: onboardingStore,
|
||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
QRCode: require('qrcode')
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL
|
||||
});
|
||||
registerPlayerRoutes(app, {
|
||||
pool: pool,
|
||||
@@ -77,7 +76,6 @@ async function start() {
|
||||
async function syncDatabaseState() {
|
||||
try {
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
await common.bootstrapDatabase(pool);
|
||||
|
||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||
await common.pruneStaleOnboardingDevices(pool);
|
||||
|
||||
@@ -99,7 +99,6 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
@@ -148,7 +147,7 @@
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -178,21 +177,10 @@
|
||||
}
|
||||
|
||||
var track = root.querySelector('.player-announcement__message-track');
|
||||
if (!track) {
|
||||
if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
|
||||
var durationSeconds = Math.max(8, singlePassWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
|
||||
}());
|
||||
</script>
|
||||
@@ -101,7 +101,6 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2.5rem;
|
||||
min-width: 100%;
|
||||
width: max-content;
|
||||
backface-visibility: hidden;
|
||||
will-change: transform;
|
||||
@@ -150,7 +149,7 @@
|
||||
transform: translate3d(0, 0, 0);
|
||||
}
|
||||
to {
|
||||
transform: translate3d(-50%, 0, 0);
|
||||
transform: translate3d(calc(-1 * var(--announcement-scroll-distance, 0px)), 0, 0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -180,21 +179,10 @@
|
||||
}
|
||||
|
||||
var track = root.querySelector('.player-announcement__message-track');
|
||||
if (!track) {
|
||||
if (!track || typeof window.initPlayerAnnouncementMarquee !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var rate = Number(track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var singlePassWidth = Math.max(1, track.scrollWidth / 2);
|
||||
var durationSeconds = Math.max(8, singlePassWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
window.initPlayerAnnouncementMarquee(track, { container: root.querySelector('.player-announcement__message') || root });
|
||||
}());
|
||||
</script>
|
||||
@@ -1,6 +1,7 @@
|
||||
// Player onboarding routes and signup flow helpers.
|
||||
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('#src/data/client-name-check');
|
||||
const { createStyledQrCodeSvg } = require('#src/data/qr-code');
|
||||
const { getSharedSecret, verifyPageAuthToken } = require('#src/request-auth');
|
||||
const { isTransientDbError } = require('./store');
|
||||
|
||||
@@ -222,11 +223,10 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
const common = options && options.common ? options.common : null;
|
||||
const playerRuntime = options && options.playerRuntime ? options.playerRuntime : null;
|
||||
const QRCode = options && options.QRCode ? options.QRCode : null;
|
||||
const onboardingStore = options && options.onboardingStore ? options.onboardingStore : null;
|
||||
|
||||
if (!app || !pool || !common || !playerRuntime || !QRCode) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, playerRuntime, and QRCode.');
|
||||
if (!app || !pool || !common || !playerRuntime) {
|
||||
throw new Error('registerPlayerOnboardingRoutes requires app, pool, common, and playerRuntime.');
|
||||
}
|
||||
|
||||
const sharedSecret = getSharedSecret();
|
||||
@@ -303,7 +303,7 @@ function registerPlayerOnboardingRoutes(app, options) {
|
||||
return res.status(400).json({ error: 'Device ID is required' });
|
||||
}
|
||||
const onboardingUrl = `${getPublicBaseUrl(req, options && options.playerPublicBaseUrl)}/onboard?deviceId=${encodeURIComponent(deviceId)}`;
|
||||
const svg = await QRCode.toString(onboardingUrl, { type: 'svg', margin: 1, errorCorrectionLevel: 'M' });
|
||||
const svg = await createStyledQrCodeSvg({ value: onboardingUrl, qr_margin: 20 });
|
||||
res.set('Content-Type', 'image/svg+xml; charset=utf-8');
|
||||
res.set('Cache-Control', 'no-store');
|
||||
res.send(svg);
|
||||
|
||||
+29
-2
@@ -3,6 +3,7 @@
|
||||
const crypto = require('crypto');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const { createStyledQrCodeDataUrl } = require('../data/qr-code');
|
||||
|
||||
function createPlayerPlaylistService(options) {
|
||||
const pool = options && options.pool ? options.pool : null;
|
||||
@@ -56,6 +57,10 @@ function createPlayerPlaylistService(options) {
|
||||
await fs.promises.writeFile(filePath, JSON.stringify(payload, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function createQrPreviewDataUrl(value) {
|
||||
return createStyledQrCodeDataUrl(value);
|
||||
}
|
||||
|
||||
async function buildScreenPlaylist(slug) {
|
||||
try {
|
||||
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM d_screens WHERE slug = ?', [slug]);
|
||||
@@ -150,12 +155,13 @@ function createPlayerPlaylistService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
const slides = slideRows.map(function (slide) {
|
||||
const slides = await Promise.all(slideRows.map(async function (slide) {
|
||||
const storedDuration = Number(slide.duration_seconds || 0);
|
||||
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
|
||||
const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio);
|
||||
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||
const content = common.parseJsonSafe(slide.content_json) || {};
|
||||
const qrBackfillTasks = [];
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (region && typeof region === 'object') {
|
||||
@@ -168,6 +174,27 @@ function createPlayerPlaylistService(options) {
|
||||
region.cache_bust = videoCacheBust;
|
||||
}
|
||||
});
|
||||
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (!region || typeof region !== 'object' || String(region.type || '').trim().toLowerCase() !== 'qr-code') {
|
||||
return;
|
||||
}
|
||||
|
||||
if (region.qr_preview && /^data:image\//i.test(String(region.qr_preview || '').trim())) {
|
||||
return;
|
||||
}
|
||||
|
||||
qrBackfillTasks.push(createQrPreviewDataUrl(region.value).then(function (preview) {
|
||||
if (preview) {
|
||||
region.qr_preview = preview;
|
||||
}
|
||||
return null;
|
||||
}).catch(function () {
|
||||
return null;
|
||||
}));
|
||||
});
|
||||
await Promise.all(qrBackfillTasks);
|
||||
return {
|
||||
id: slide.id,
|
||||
title: slide.title,
|
||||
@@ -180,7 +207,7 @@ function createPlayerPlaylistService(options) {
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: content
|
||||
};
|
||||
});
|
||||
}));
|
||||
|
||||
let rssFeeds = [];
|
||||
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||
|
||||
@@ -116,6 +116,8 @@ body.thumbnail-preview .player-offline-banner {
|
||||
display: block;
|
||||
background: #fff;
|
||||
border-radius: 18px;
|
||||
padding: 16px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.onboarding-form {
|
||||
|
||||
@@ -54,12 +54,68 @@
|
||||
});
|
||||
}
|
||||
|
||||
function getComputedGapWidth(track) {
|
||||
if (!track || !window.getComputedStyle) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
var computedStyle = window.getComputedStyle(track);
|
||||
var gapValue = String(computedStyle.columnGap || computedStyle.gap || '0').trim();
|
||||
var gap = Number.parseFloat(gapValue);
|
||||
return Number.isFinite(gap) ? gap : 0;
|
||||
}
|
||||
|
||||
function getElementWidth(element) {
|
||||
if (!element || !element.getBoundingClientRect) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
return Math.max(0, element.getBoundingClientRect().width || 0);
|
||||
}
|
||||
|
||||
function initPlayerAnnouncementMarquee(track, options) {
|
||||
if (!track || track.dataset.marqueeMeasured === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var settings = options || {};
|
||||
var rate = Number(settings.rate || track.getAttribute('data-announcement-marquee-rate') || 44) || 44;
|
||||
var textSelector = String(settings.textSelector || '.player-announcement__message-text').trim() || '.player-announcement__message-text';
|
||||
var textNodes = Array.prototype.slice.call(track.querySelectorAll(textSelector));
|
||||
if (!textNodes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = settings.container || track.parentElement || track;
|
||||
var containerWidth = Math.max(1, getElementWidth(container) || 0);
|
||||
var gapWidth = getComputedGapWidth(track);
|
||||
var firstItemWidth = Math.max(1, getElementWidth(textNodes[0]) || 0);
|
||||
var cycleWidth = Math.max(1, firstItemWidth + gapWidth);
|
||||
var minimumTrackWidth = Math.max(containerWidth + cycleWidth, cycleWidth * 2);
|
||||
var cloneIndex = 0;
|
||||
|
||||
while (getElementWidth(track) < minimumTrackWidth && cloneIndex < 8) {
|
||||
var clone = textNodes[cloneIndex % textNodes.length].cloneNode(true);
|
||||
clone.setAttribute('aria-hidden', 'true');
|
||||
track.appendChild(clone);
|
||||
cloneIndex += 1;
|
||||
}
|
||||
|
||||
var durationSeconds = Math.max(8, cycleWidth / rate);
|
||||
var durationValue = durationSeconds.toFixed(2) + 's';
|
||||
|
||||
track.dataset.marqueeMeasured = 'true';
|
||||
track.style.setProperty('--announcement-scroll-distance', cycleWidth.toFixed(2) + 'px');
|
||||
track.style.setProperty('--announcement-scroll-duration', durationValue);
|
||||
track.style.animationDuration = durationValue;
|
||||
}
|
||||
|
||||
function renderPlayerAnnouncementMarkup(announcement) {
|
||||
var normalizedType = normalizeAnnouncementType(announcement && announcement.announcement_type);
|
||||
var colorKey = normalizeAnnouncementColor(announcement && announcement.color_key);
|
||||
var colorTokens = getAnnouncementColorTokens(colorKey);
|
||||
var iconKey = normalizeAnnouncementIcon(announcement && announcement.icon_key);
|
||||
var text = escapeHtml(String(announcement && announcement.message || '').trim()).replace(/\n/g, '<br />');
|
||||
var text = escapeHtml(String(announcement && announcement.message || '').trim()).replace(/[\r\n]+/g, ' ');
|
||||
var titleText = escapeHtml(String(announcement && announcement.short_label || 'Announcement').trim() || 'Announcement');
|
||||
var templates = window.playerAnnouncementTemplates || {};
|
||||
var templateName = normalizedType === 'fullscreen' ? 'fullscreen' : normalizedType === 'top-banner' ? 'topBanner' : 'lowerThird';
|
||||
@@ -77,4 +133,5 @@
|
||||
}
|
||||
|
||||
window.renderPlayerAnnouncementMarkup = renderPlayerAnnouncementMarkup;
|
||||
window.initPlayerAnnouncementMarquee = initPlayerAnnouncementMarquee;
|
||||
})();
|
||||
@@ -3,14 +3,18 @@
|
||||
var registry = window.pulsePlayerRegionTypes;
|
||||
|
||||
function renderQrCodeRegion(region, regionContent) {
|
||||
var svg = String(regionContent && regionContent.qr_svg || '').trim();
|
||||
if (!svg) {
|
||||
var borderRadius = Math.max(0, Math.round(Number(regionContent && regionContent.qr_border_radius || 0)));
|
||||
var radiusStyle = borderRadius > 0 ? 'border-radius:' + borderRadius + 'px;overflow:hidden;' : '';
|
||||
var preview = String(regionContent && regionContent.qr_preview || '').trim();
|
||||
|
||||
if (!preview) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<div class="template-region qr-code" style="' + region.baseStyle + '"><img class="template-region-qr-code-image" src="data:image/svg+xml;charset=utf-8,' + encodeURIComponent(svg) + '" alt="QR code" /></div>';
|
||||
return '<div class="template-region qr-code" style="' + region.baseStyle + radiusStyle + '"><img class="template-region-qr-code-image" src="' + escapeHtml(preview) + '" alt="QR code" role="img" draggable="false" style="background:transparent;display:block;width:100%;height:100%;object-fit:contain;' + radiusStyle + '" /></div>';
|
||||
}
|
||||
|
||||
registry.register('qr-code', {
|
||||
renderRegion: renderQrCodeRegion
|
||||
renderRegion: renderQrCodeRegion,
|
||||
initRegion: function () {}
|
||||
});
|
||||
@@ -40,7 +40,7 @@ function renderTextRegion(region, regionContent) {
|
||||
var fontFamily = sanitizeFontFamily(regionContent.font_family || region.fontFamily || defaultStyle.font_family);
|
||||
var fontSize = sanitizeFontSize(regionContent.font_size || region.fontSize || defaultStyle.font_size);
|
||||
var fontColor = sanitizeTextColor(regionContent.font_color || region.fontColor || defaultStyle.font_color);
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||
var contentStyle = 'width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;transform:scale(' + region.canvasScale + ');transform-origin:top left;line-height:1.5;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor) + ';';
|
||||
var renderedBody = renderEditorJsContent(rawValue);
|
||||
return renderedBody ? '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="' + contentStyle + '">' + renderedBody + '</div></div>' : '';
|
||||
}
|
||||
|
||||
@@ -442,8 +442,9 @@ function getPlayerRegionScriptPaths() {
|
||||
|
||||
function getPlayerRegionScripts() {
|
||||
const sharedPlaceholderUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'placeholder-utils.js');
|
||||
const sharedQrSvgUtilsPath = path.join(__dirname, '..', 'web', 'public', 'js', 'shared', 'qr-code-svg.js');
|
||||
const playerRegionScriptPaths = getPlayerRegionScriptPaths();
|
||||
const scriptPaths = [sharedPlaceholderUtilsPath].concat(playerRegionScriptPaths);
|
||||
const scriptPaths = [sharedPlaceholderUtilsPath, sharedQrSvgUtilsPath].concat(playerRegionScriptPaths);
|
||||
const statSignature = scriptPaths.map(function (filePath) {
|
||||
return fs.statSync(filePath).mtimeMs;
|
||||
}).join('|');
|
||||
|
||||
+191
-178
@@ -2,202 +2,215 @@
|
||||
|
||||
const PERMISSION_SECTIONS = [
|
||||
{
|
||||
key: 'dashboard',
|
||||
sectionName: 'Main navigation',
|
||||
order: 10,
|
||||
name: 'Dashboard',
|
||||
sectionName: 'Main navigation',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'dashboard',
|
||||
order: 10,
|
||||
name: 'Dashboard',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'Access the dashboard overview.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Send global player commands.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'clients',
|
||||
order: 20,
|
||||
name: 'Connected clients',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'clients',
|
||||
sectionName: 'Content',
|
||||
order: 20,
|
||||
name: 'Connected clients',
|
||||
sectionName: 'Main navigation',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View connected player clients and live status.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Use the connected client command buttons.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'screens',
|
||||
order: 10,
|
||||
name: 'Screens',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new screens.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update screens.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'playlists',
|
||||
order: 20,
|
||||
name: 'Playlists',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'slides',
|
||||
order: 30,
|
||||
name: 'Slides',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slides.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slides.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide content.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
order: 40,
|
||||
name: 'Slide templates',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slide templates.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide templates.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'canvas-sizes',
|
||||
order: 50,
|
||||
name: 'Canvas sizes',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 60,
|
||||
name: 'Announcements',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View announcements.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update announcements.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'screens',
|
||||
sectionName: 'Data Sources',
|
||||
order: 30,
|
||||
name: 'Screens',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View the screen list and open screen details.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new screens.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update screens.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete screens.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'rss-feeds',
|
||||
order: 10,
|
||||
name: 'RSS feeds',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'api-sources',
|
||||
order: 20,
|
||||
name: 'API sources',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured API sources.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new API sources.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update API sources.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'timetables',
|
||||
order: 30,
|
||||
name: 'Timetables',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured timetable groups.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new timetable groups.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'playlists',
|
||||
sectionName: 'Settings',
|
||||
order: 40,
|
||||
name: 'Playlists',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View playlists and playlist contents.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new playlists.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update playlists and playlist slides.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete playlists.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'slides',
|
||||
order: 50,
|
||||
name: 'Slides',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slides.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slides.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide content.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slides.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'templates',
|
||||
order: 60,
|
||||
name: 'Slide templates',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View slide templates.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new slide templates.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update slide templates.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete slide templates.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'canvas-sizes',
|
||||
order: 70,
|
||||
name: 'Canvas sizes',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View canvas sizes.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new canvas sizes.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update canvas sizes.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete canvas sizes.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'announcements',
|
||||
order: 80,
|
||||
name: 'Announcements',
|
||||
sectionName: 'Content',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View announcements.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new announcements.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update announcements.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete announcements.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rss-feeds',
|
||||
order: 90,
|
||||
name: 'RSS feeds',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured RSS feeds.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new RSS feeds.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update RSS feeds.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete RSS feeds.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'api-sources',
|
||||
order: 100,
|
||||
name: 'API sources',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured API sources.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new API sources.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update API sources.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete API sources.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'timetables',
|
||||
order: 110,
|
||||
name: 'Timetables',
|
||||
sectionName: 'Data Sources',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View configured timetable groups.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new timetable groups.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update timetable groups and entries.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete timetable groups.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
order: 120,
|
||||
name: 'Users',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new users.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 130,
|
||||
name: 'Roles and permissions',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new roles.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 140,
|
||||
name: 'Fonts',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 150,
|
||||
name: 'Task Queue',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 160,
|
||||
name: 'Scheduled tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
permissions: [
|
||||
{
|
||||
key: 'users',
|
||||
order: 10,
|
||||
name: 'Users',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View users and role assignments.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new users.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update users, passwords, and role assignments.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete users.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'rbac',
|
||||
order: 20,
|
||||
name: 'Roles and permissions',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View roles and permissions.' },
|
||||
{ key: 'create', name: 'Create', description: 'Create new roles.' },
|
||||
{ key: 'update', name: 'Update', description: 'Update role details and permissions.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Delete roles.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'fonts',
|
||||
order: 30,
|
||||
name: 'Fonts',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View managed fonts.' },
|
||||
{ key: 'create', name: 'Create', description: 'Upload managed fonts.' },
|
||||
{ key: 'delete', name: 'Delete', description: 'Remove managed fonts.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'background-tasks',
|
||||
order: 40,
|
||||
name: 'Task Queue',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 50,
|
||||
name: 'Scheduled tasks',
|
||||
permissions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
];
|
||||
|
||||
const PERMISSIONS = PERMISSION_SECTIONS.flatMap(function (section) {
|
||||
return section.actions.map(function (action) {
|
||||
return {
|
||||
key: `${section.key}.${action.key}`,
|
||||
name: section.name,
|
||||
sectionOrder: section.order,
|
||||
actionName: action.name,
|
||||
sectionName: section.sectionName,
|
||||
sectionKey: section.key,
|
||||
actionKey: action.key,
|
||||
description: action.description
|
||||
};
|
||||
return (Array.isArray(section.permissions) ? section.permissions : []).flatMap(function (resource) {
|
||||
return (Array.isArray(resource.permissions) ? resource.permissions : []).map(function (action, index) {
|
||||
return {
|
||||
key: `${resource.key}.${action.key}`,
|
||||
name: resource.name,
|
||||
sectionOrder: section.order,
|
||||
actionName: action.name,
|
||||
permissionOrder: index + 1,
|
||||
sectionName: section.sectionName,
|
||||
resourceKey: resource.key,
|
||||
resourceOrder: resource.order,
|
||||
resourceName: resource.name,
|
||||
actionKey: action.key,
|
||||
description: action.description
|
||||
};
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+2
-1
@@ -6,7 +6,7 @@ const multer = require('multer');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const common = require('./common');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('#src/auth');
|
||||
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword, validatePasswordStrength } = require('#src/auth');
|
||||
const pages = require('#src/web/pages');
|
||||
const registerMiddleware = require('#src/web/middleware');
|
||||
const { createBackgroundTaskQueue } = require('#src/web/lib/background-tasks/queue');
|
||||
@@ -131,6 +131,7 @@ async function start() {
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getAuditUserId: getAuditUserId,
|
||||
hashPassword: hashPassword,
|
||||
validatePasswordStrength: validatePasswordStrength,
|
||||
readArrayField: readArrayField,
|
||||
rbacData: rbacData,
|
||||
fetchOrderedPlaylistSlides: fetchOrderedPlaylistSlides,
|
||||
|
||||
+14
-2
@@ -28,6 +28,15 @@ function registerGeneralHelpers(Handlebars) {
|
||||
|
||||
return `${text.slice(0, Math.max(0, limit - 1)).trimEnd()}…`;
|
||||
});
|
||||
|
||||
Handlebars.registerHelper('textLengthAttrs', function (maxLength) {
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit < 1) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Handlebars.SafeString(`maxlength="${Handlebars.escapeExpression(String(Math.floor(limit)))}" data-limit-text-length`);
|
||||
});
|
||||
}
|
||||
|
||||
// URL helpers for generating links and external URL checks.
|
||||
@@ -112,12 +121,14 @@ function registerActionHelpers(Handlebars) {
|
||||
label: String(hash.deleteLabel || 'Delete'),
|
||||
className: String(hash.deleteClass || 'btn btn-danger'),
|
||||
url: String(hash.deleteUrl || '').trim(),
|
||||
formId: String(hash.deleteFormId || '').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' : '');
|
||||
const resolvedDeleteFormId = deleteAction.formId || '';
|
||||
|
||||
if (!formId) {
|
||||
return '';
|
||||
@@ -141,10 +152,11 @@ function registerActionHelpers(Handlebars) {
|
||||
}
|
||||
|
||||
if (showDelete && (deleteAction.disabled || resolvedDeleteUrl)) {
|
||||
const deleteActionAttr = resolvedDeleteUrl ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
|
||||
const deleteActionAttr = resolvedDeleteUrl && !resolvedDeleteFormId ? ` data-delete-action-url="${escape(resolvedDeleteUrl)}"` : '';
|
||||
const deleteFormAttr = resolvedDeleteFormId ? ` form="${escape(resolvedDeleteFormId)}"` : '';
|
||||
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(`<button type="${resolvedDeleteFormId ? 'submit' : 'button'}" class="${escape(deleteAction.className)}"${deleteFormAttr}${deleteActionAttr}${confirmAttr}${deleteAction.disabled ? ' disabled' : ''}${titleAttr}>${escape(deleteAction.label)}</button>`);
|
||||
}
|
||||
|
||||
parts.push('</div>');
|
||||
|
||||
@@ -2,6 +2,35 @@
|
||||
|
||||
const { normalizePermissionKeys } = require('#src/rbac');
|
||||
|
||||
function getRequestOrigin(req) {
|
||||
const forwardedProto = String(req && req.headers && req.headers['x-forwarded-proto'] || '').trim().split(',')[0];
|
||||
const protocol = forwardedProto || (req && req.socket && req.socket.encrypted ? 'https' : 'http');
|
||||
const forwardedHost = String(req && req.headers && req.headers['x-forwarded-host'] || '').trim().split(',')[0];
|
||||
const host = forwardedHost || String(req && req.headers && req.headers.host || '').trim();
|
||||
return host ? `${protocol}://${host}`.replace(/\/$/, '') : 'http://localhost';
|
||||
}
|
||||
|
||||
function normalizeReturnToPath(value, baseUrl) {
|
||||
const rawValue = String(value || '').trim();
|
||||
if (!rawValue) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const normalizedBaseUrl = String(baseUrl || '').trim().replace(/\/$/, '') || 'http://localhost';
|
||||
|
||||
try {
|
||||
const parsed = new URL(rawValue, normalizedBaseUrl);
|
||||
const baseOrigin = new URL(normalizedBaseUrl).origin;
|
||||
if (parsed.origin !== baseOrigin) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return parsed.pathname + parsed.search + parsed.hash;
|
||||
} catch (_error) {
|
||||
return rawValue.charAt(0) === '/' ? rawValue : '';
|
||||
}
|
||||
}
|
||||
|
||||
function createSessionService(options) {
|
||||
const sessionCookieName = String(options && options.sessionCookieName || '').trim();
|
||||
const sessionMaxAgeMs = Number(options && options.sessionMaxAgeMs);
|
||||
@@ -142,7 +171,8 @@ function createSessionService(options) {
|
||||
return next();
|
||||
}
|
||||
setAuthMessageCookie(res, 'Please sign in to continue.');
|
||||
res.redirect('/login');
|
||||
const returnTo = normalizeReturnToPath(req && (req.originalUrl || req.url || req.path), getRequestOrigin(req));
|
||||
res.redirect(returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login');
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -154,8 +184,9 @@ function createSessionService(options) {
|
||||
consumeAuthMessageCookie: consumeAuthMessageCookie,
|
||||
loadCurrentUser: loadCurrentUser,
|
||||
createUserSession: createUserSession,
|
||||
requireAuth: requireAuth
|
||||
requireAuth: requireAuth,
|
||||
getRequestOrigin: getRequestOrigin
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createSessionService };
|
||||
module.exports = { createSessionService, normalizeReturnToPath, getRequestOrigin };
|
||||
@@ -115,9 +115,11 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
}
|
||||
|
||||
if (regionType === 'qr-code') {
|
||||
const src = String(regionContent.qr_svg || rawValue || regionContent.qr_code || '').trim();
|
||||
const src = String(regionContent.qr_preview || '').trim();
|
||||
const borderRadius = Math.max(0, Math.round(Number(regionContent.qr_border_radius || 0)));
|
||||
const radiusStyle = borderRadius > 0 ? ' style="border-radius:' + borderRadius + 'px;overflow:hidden;"' : '';
|
||||
return src
|
||||
? '<img src="data:image/svg+xml;charset=utf-8,' + encodeURIComponent(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '" />'
|
||||
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'qr code') + '"' + radiusStyle + ' />'
|
||||
: '';
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
const express = require('express');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = function registerMiddleware(app, deps) {
|
||||
const pool = deps.pool;
|
||||
@@ -12,12 +11,10 @@ module.exports = function registerMiddleware(app, deps) {
|
||||
const uploadsDir = deps.UPLOADS_DIR;
|
||||
const thumbnailsDir = deps.THUMBNAILS_DIR;
|
||||
const assetDir = deps.ASSET_DIR;
|
||||
const qrcodeGeneratorDir = path.join(__dirname, '..', '..', 'node_modules', 'qrcode-generator', 'dist');
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(assetDir));
|
||||
app.use('/assets/vendor/qrcode-generator', express.static(qrcodeGeneratorDir));
|
||||
app.use('/media', express.static(mediaDir));
|
||||
fs.mkdirSync(uploadsDir, { recursive: true });
|
||||
fs.mkdirSync(thumbnailsDir, { recursive: true });
|
||||
|
||||
@@ -259,6 +259,10 @@
|
||||
opacity: 0.84;
|
||||
}
|
||||
|
||||
.rbac-permissions-table tbody tr:last-child > * {
|
||||
border-bottom-width: 0;
|
||||
}
|
||||
|
||||
.template-designer-form--previewing .region-item [disabled],
|
||||
.template-designer-form--previewing .template-details-card [disabled],
|
||||
.template-designer-form--previewing .template-options-card [disabled],
|
||||
@@ -270,15 +274,33 @@
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.announcement-type-picker {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-height: calc(1.5em + 0.75rem + 2px);
|
||||
min-height: calc(2.375rem + 2px);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.375rem;
|
||||
border-radius: 0.875rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.announcement-type-picker__toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
width: 100%;
|
||||
min-height: calc(2.375rem + 2px);
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.875rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
@@ -289,23 +311,73 @@
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.announcement-type-picker__toggle:focus-visible {
|
||||
outline: 0;
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.5rem;
|
||||
border-radius: 0.7rem;
|
||||
background: rgba(var(--bs-primary-rgb), 0.1);
|
||||
color: var(--bs-primary);
|
||||
font-size: 1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle-label {
|
||||
.announcement-type-picker__toggle-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
border-radius: 0.7rem;
|
||||
background: rgba(var(--bs-primary-rgb), 0.1);
|
||||
color: var(--bs-primary);
|
||||
font-size: 1rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.announcement-type-picker__toggle-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle-label {
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.announcement-type-picker__toggle-label {
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__toggle-hint {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.announcement-type-picker__toggle-hint {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.78rem;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__caret {
|
||||
@@ -314,6 +386,12 @@
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.announcement-type-picker__caret {
|
||||
flex: 0 0 auto;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
@@ -329,6 +407,26 @@
|
||||
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.announcement-type-picker__menu {
|
||||
position: absolute;
|
||||
z-index: 20;
|
||||
top: calc(100% + 0.5rem);
|
||||
bottom: auto;
|
||||
left: 0;
|
||||
width: min(100%, 28rem);
|
||||
max-width: calc(100vw - 1rem);
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 1rem;
|
||||
background: var(--bs-body-bg);
|
||||
box-shadow: 0 1rem 2rem rgba(15, 23, 42, 0.18);
|
||||
}
|
||||
|
||||
.announcement-type-picker__menu.is-open-above {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__menu.is-open-above {
|
||||
top: auto;
|
||||
bottom: calc(100% + 0.5rem);
|
||||
@@ -350,6 +448,12 @@
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.announcement-type-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(8rem, 1fr));
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -363,6 +467,20 @@
|
||||
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.announcement-type-picker__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.5rem;
|
||||
padding: 0.5rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option:hover,
|
||||
.announcement-icon-picker__option:focus-visible {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.7);
|
||||
@@ -371,6 +489,14 @@
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.announcement-type-picker__option:hover,
|
||||
.announcement-type-picker__option:focus-visible {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.7);
|
||||
background: rgba(var(--bs-primary-rgb), 0.04);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option.is-selected {
|
||||
border-color: var(--bs-primary);
|
||||
background: rgba(var(--bs-primary-rgb), 0.12);
|
||||
@@ -378,11 +504,154 @@
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.announcement-type-picker__option.is-selected {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.announcement-icon-picker__option i {
|
||||
font-size: 1.05rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.announcement-type-picker__option-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.5rem;
|
||||
border-radius: 0.65rem;
|
||||
background: rgba(var(--bs-primary-rgb), 0.1);
|
||||
color: var(--bs-primary);
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.announcement-type-picker__option-icon i {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.announcement-type-picker__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.announcement-screen-picker {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
gap: 0.5rem;
|
||||
max-height: 18rem;
|
||||
padding: 0.25rem;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.announcement-screen-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.announcement-screen-action {
|
||||
flex: 1 1 9rem;
|
||||
}
|
||||
|
||||
.announcement-screen-option {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.announcement-screen-option > .btn-check {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.announcement-screen-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
min-height: 2.5rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.875rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
cursor: pointer;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.announcement-screen-chip:hover,
|
||||
.announcement-screen-chip:focus-visible {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.7);
|
||||
background: rgba(var(--bs-primary-rgb), 0.04);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.announcement-screen-option > .btn-check:checked + .announcement-screen-chip {
|
||||
border-color: var(--bs-primary);
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.announcement-screen-chip__check {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
margin-left: auto;
|
||||
width: 1.25rem;
|
||||
height: 1.25rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(var(--bs-primary-rgb), 0.1);
|
||||
color: var(--bs-primary);
|
||||
opacity: 0;
|
||||
transform: scale(0.85);
|
||||
transition: opacity 120ms ease, transform 120ms ease, background-color 120ms ease, color 120ms ease;
|
||||
}
|
||||
|
||||
.announcement-screen-chip__check-icon {
|
||||
font-size: 0.9rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.announcement-screen-option > .btn-check:checked + .announcement-screen-chip .announcement-screen-chip__check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.announcement-screen-chip__text {
|
||||
display: block;
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.announcement-screen-picker {
|
||||
max-height: 16rem;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.announcement-type-picker__menu {
|
||||
width: calc(100vw - 2rem);
|
||||
right: 0;
|
||||
left: auto;
|
||||
}
|
||||
|
||||
.announcement-type-picker__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(7rem, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 576px) {
|
||||
.announcement-icon-picker__menu {
|
||||
width: calc(100vw - 2rem);
|
||||
@@ -1844,6 +2113,83 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
border-radius: var(--bs-border-radius);
|
||||
}
|
||||
|
||||
.announcement-color-picker {
|
||||
display: grid;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.announcement-color-picker__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(8, minmax(0, 1fr));
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.announcement-color-picker__option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem;
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bs-body-bg);
|
||||
color: var(--bs-body-color);
|
||||
text-align: left;
|
||||
transition: transform 120ms ease, border-color 120ms ease, background-color 120ms ease, box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.announcement-color-picker__option:hover,
|
||||
.announcement-color-picker__option:focus-visible {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.7);
|
||||
background: rgba(var(--bs-primary-rgb), 0.04);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.15);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.announcement-color-picker__option.is-selected {
|
||||
border-color: var(--bs-primary);
|
||||
box-shadow: 0 0 0 0.15rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.announcement-color-picker__swatch {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: 2.5rem;
|
||||
border-radius: 0.65rem;
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
.announcement-color-picker__check {
|
||||
opacity: 0;
|
||||
transform: scale(0.8);
|
||||
transition: opacity 120ms ease, transform 120ms ease;
|
||||
}
|
||||
|
||||
.announcement-color-picker__option.is-selected .announcement-color-picker__check {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
.announcement-color-picker__label {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (max-width: 1200px) {
|
||||
.announcement-color-picker__grid {
|
||||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.announcement-color-picker__grid {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
.announcement-icon-preview {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1871,7 +2217,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
display: grid;
|
||||
gap: 0.5rem;
|
||||
padding-top: 0.25rem;
|
||||
margin-bottom: 0.75rem;
|
||||
padding-bottom: 0.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.api-region-placeholder-title {
|
||||
@@ -2220,6 +2567,50 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
flex: 1 1 0;
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-option {
|
||||
position: relative;
|
||||
display: flex;
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-option > .btn-check {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-option > .schedule-day-button {
|
||||
width: 100%;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-option:first-child > .schedule-day-button {
|
||||
border-top-left-radius: var(--bs-btn-border-radius);
|
||||
border-bottom-left-radius: var(--bs-btn-border-radius);
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-option:last-child > .schedule-day-button {
|
||||
border-top-right-radius: var(--bs-btn-border-radius);
|
||||
border-bottom-right-radius: var(--bs-btn-border-radius);
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-option + .schedule-day-option {
|
||||
margin-left: -1px;
|
||||
}
|
||||
|
||||
.schedule-day-grid .schedule-day-button.is-invalid {
|
||||
color: var(--bs-danger);
|
||||
border-color: var(--bs-danger);
|
||||
background-color: rgba(var(--bs-danger-rgb), 0.08);
|
||||
}
|
||||
|
||||
.schedule-day-grid .btn-check:checked + .schedule-day-button.is-invalid {
|
||||
color: #fff;
|
||||
border-color: var(--bs-danger);
|
||||
background-color: var(--bs-danger);
|
||||
}
|
||||
|
||||
.slide-schedule-dialog {
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
@@ -2283,11 +2674,9 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
||||
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-body,
|
||||
.slide-schedule-content .schedule-rule-card.collapsed-card > .card-footer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slide-schedule-content .schedule-rule-card.collapsed-card .card-header {
|
||||
@@ -2332,8 +2721,18 @@ td[data-label="Actions"] > div {
|
||||
}
|
||||
|
||||
.playlist-duration-input {
|
||||
width: 6rem;
|
||||
min-width: 6rem;
|
||||
width: 4rem !important;
|
||||
min-width: 4rem !important;
|
||||
flex: 0 0 4rem !important;
|
||||
}
|
||||
|
||||
.playlist-duration-input-group {
|
||||
display: inline-flex !important;
|
||||
flex: 0 0 auto !important;
|
||||
width: fit-content !important;
|
||||
max-width: fit-content !important;
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-duration-input::-webkit-outer-spin-button,
|
||||
@@ -2356,6 +2755,13 @@ td[data-label="Actions"] > div {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.playlist-duration-prefix {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playlist-use-video-duration {
|
||||
white-space: nowrap;
|
||||
}
|
||||
@@ -2424,13 +2830,24 @@ table.table > :not(caption) > * > * {
|
||||
.playlist-order-cell {
|
||||
width: 1%;
|
||||
white-space: nowrap;
|
||||
vertical-align: top;
|
||||
padding-top: 0 !important;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.playlist-order-cell-inner {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.playlist-order-stepper {
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.02rem;
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
@@ -2438,26 +2855,46 @@ table.table > :not(caption) > * > * {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.35rem;
|
||||
height: 1.9rem;
|
||||
height: 1.35rem;
|
||||
line-height: 1;
|
||||
cursor: grab;
|
||||
cursor: grab !important;
|
||||
text-decoration: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.playlist-drag-handle:hover,
|
||||
.playlist-drag-handle:focus {
|
||||
cursor: grab !important;
|
||||
}
|
||||
|
||||
.playlist-drag-handle:active,
|
||||
.playlist-drag-handle[aria-grabbed="true"] {
|
||||
cursor: grabbing !important;
|
||||
}
|
||||
|
||||
.playlist-drag-handle-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.1rem;
|
||||
height: 1.9rem;
|
||||
height: 1.1rem;
|
||||
font-size: 1rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.playlist-drag-handle-svg {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
fill: currentColor;
|
||||
.playlist-order-move {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 1.15rem;
|
||||
height: 1.15rem;
|
||||
line-height: 1;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.playlist-order-move:hover,
|
||||
.playlist-order-move:focus {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.playlist-drag-handle:active,
|
||||
|
||||
@@ -157,10 +157,14 @@
|
||||
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
|
||||
}
|
||||
|
||||
function isRegionEditorField(target) {
|
||||
return Boolean(target && typeof target.closest === 'function' && target.closest('.region-item'));
|
||||
}
|
||||
|
||||
function initDirtyTracking() {
|
||||
document.addEventListener('input', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
if (!target || !target.form || isRegionEditorField(target)) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
@@ -168,13 +172,101 @@
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
if (!target || !target.form || isRegionEditorField(target)) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function clampLimitedInputValue(input) {
|
||||
if (!input || !input.matches || !input.matches('input[data-limit-input-value]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var minAttr = input.getAttribute('min');
|
||||
var maxAttr = input.getAttribute('max');
|
||||
var minValue = minAttr === null || minAttr === '' ? null : Number(minAttr);
|
||||
var maxValue = maxAttr === null || maxAttr === '' ? null : Number(maxAttr);
|
||||
var rawValue = String(input.value || '').trim();
|
||||
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
var numericValue = Number(rawValue);
|
||||
if (!Number.isFinite(numericValue)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(minValue) && numericValue < minValue) {
|
||||
input.value = String(minValue);
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number.isFinite(maxValue) && numericValue > maxValue) {
|
||||
input.value = String(maxValue);
|
||||
}
|
||||
}
|
||||
|
||||
function initLimitedInputValues() {
|
||||
document.addEventListener('input', function (event) {
|
||||
clampLimitedInputValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
clampLimitedInputValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('paste', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.matches || !target.matches('input[data-limit-input-value]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
clampLimitedInputValue(target);
|
||||
}, 0);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function clampLimitedTextValue(input) {
|
||||
if (!input || !input.matches || !input.matches('input[data-limit-text-length], textarea[data-limit-text-length]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
var maxLength = Number(input.getAttribute('maxlength'));
|
||||
if (!Number.isFinite(maxLength) || maxLength < 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
var value = String(input.value || '');
|
||||
if (value.length > maxLength) {
|
||||
input.value = value.slice(0, maxLength);
|
||||
}
|
||||
}
|
||||
|
||||
function initLimitedTextInputValues() {
|
||||
document.addEventListener('input', function (event) {
|
||||
clampLimitedTextValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
clampLimitedTextValue(event.target);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('paste', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.matches || !target.matches('input[data-limit-text-length], textarea[data-limit-text-length]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
clampLimitedTextValue(target);
|
||||
}, 0);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initCancelConfirm() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
|
||||
@@ -219,6 +311,15 @@
|
||||
return String(responseText || '').trim();
|
||||
}
|
||||
|
||||
function isWarningSaveError(error) {
|
||||
var status = Number(error && error.status);
|
||||
if (status === 400 || status === 422) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return /region names must be unique/i.test(String(error && error.message || ''));
|
||||
}
|
||||
|
||||
function getAsyncSaveActionField(form) {
|
||||
if (!form || !form.querySelector) {
|
||||
return null;
|
||||
@@ -312,7 +413,9 @@
|
||||
|
||||
if (!response.ok) {
|
||||
var responseText = await response.text();
|
||||
throw new Error(parseAsyncSaveErrorMessage(responseText) || (settings.errorMessage || 'Unable to save changes.'));
|
||||
var error = new Error(parseAsyncSaveErrorMessage(responseText) || (settings.errorMessage || 'Unable to save changes.'));
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (isLoginRedirect(response)) {
|
||||
@@ -363,11 +466,25 @@
|
||||
})
|
||||
: parseAsyncSaveResponseMessage(responseText);
|
||||
|
||||
showToast(successMessage || String(settings.fallbackSuccessMessage || 'Saved.'), 'success');
|
||||
var resolvedSuccessMessage = successMessage || String(settings.fallbackSuccessMessage || 'Saved.');
|
||||
|
||||
if (form.dataset && form.dataset.asyncSaveRefreshPage === 'true') {
|
||||
if (typeof window.queueToastAfterRefresh === 'function') {
|
||||
window.queueToastAfterRefresh(resolvedSuccessMessage, 'success');
|
||||
} else if (typeof showToast === 'function') {
|
||||
showToast(resolvedSuccessMessage, 'success');
|
||||
}
|
||||
window.setTimeout(function () {
|
||||
window.location.reload();
|
||||
}, 1000);
|
||||
return true;
|
||||
}
|
||||
|
||||
showToast(resolvedSuccessMessage, 'success');
|
||||
return true;
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
var variant = isWarningSaveError(error) ? '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.'));
|
||||
@@ -822,7 +939,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = formatDashboardDate(date);
|
||||
element.textContent = formatDashboardDate(date, element.getAttribute('data-local-datetime-format') === '24h');
|
||||
});
|
||||
}
|
||||
|
||||
@@ -862,6 +979,8 @@
|
||||
initConfirmForms();
|
||||
initDeleteButtons();
|
||||
initDirtyTracking();
|
||||
initLimitedInputValues();
|
||||
initLimitedTextInputValues();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
@@ -872,6 +991,10 @@
|
||||
attachSlideThumbFallbacks(document);
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
window.clampLimitedInputValue = clampLimitedInputValue;
|
||||
window.initLimitedInputValues = initLimitedInputValues;
|
||||
window.clampLimitedTextValue = clampLimitedTextValue;
|
||||
window.initLimitedTextInputValues = initLimitedTextInputValues;
|
||||
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
|
||||
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
|
||||
}());
|
||||
|
||||
@@ -18,21 +18,127 @@ function updateAnnouncementDurationFields() {
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnnouncementColorPreview() {
|
||||
var colorSelect = document.getElementById('announcement-color');
|
||||
var preview = document.querySelector('[data-announcement-color-preview]');
|
||||
if (!colorSelect || !preview) {
|
||||
function updateAnnouncementColorPickerSelection() {
|
||||
var colorInput = document.getElementById('announcement-color');
|
||||
var picker = document.querySelector('[data-announcement-color-picker-shell]');
|
||||
if (!colorInput || !picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedOption = colorSelect.options[colorSelect.selectedIndex] || null;
|
||||
var colorKey = String(colorSelect.value || '').trim().toLowerCase() || 'primary';
|
||||
var label = selectedOption ? String(selectedOption.textContent || selectedOption.label || colorKey).trim() : colorKey;
|
||||
var selectedValue = String(colorInput.value || '').trim().toLowerCase() || 'primary';
|
||||
picker.querySelectorAll('[data-announcement-color-option]').forEach(function (button) {
|
||||
var isSelected = String(button.getAttribute('data-color-key') || '').trim().toLowerCase() === selectedValue;
|
||||
button.classList.toggle('is-selected', isSelected);
|
||||
button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
preview.className = 'announcement-color-preview text-bg-' + colorKey;
|
||||
preview.setAttribute('aria-label', label);
|
||||
preview.setAttribute('title', label);
|
||||
preview.textContent = '';
|
||||
function setAnnouncementScreenSelection(isSelected) {
|
||||
document.querySelectorAll('input[name="screen_ids[]"]').forEach(function (input) {
|
||||
input.checked = isSelected;
|
||||
});
|
||||
}
|
||||
|
||||
function positionAnnouncementTypePicker() {
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
var shell = document.querySelector('[data-announcement-type-picker-shell]');
|
||||
if (!picker || picker.hidden || !toggle || !shell) {
|
||||
return;
|
||||
}
|
||||
|
||||
var padding = 8;
|
||||
var toggleRect = toggle.getBoundingClientRect();
|
||||
var menuRect = picker.getBoundingClientRect();
|
||||
var viewportHeight = window.innerHeight || document.documentElement.clientHeight || toggleRect.bottom;
|
||||
var placementAbove = false;
|
||||
var spaceBelow = viewportHeight - toggleRect.bottom - padding;
|
||||
var spaceAbove = toggleRect.top - padding;
|
||||
|
||||
if (menuRect.height > spaceBelow && spaceAbove > spaceBelow) {
|
||||
placementAbove = true;
|
||||
}
|
||||
|
||||
picker.classList.toggle('is-open-above', placementAbove);
|
||||
}
|
||||
|
||||
function closeAnnouncementTypePicker() {
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
picker.hidden = true;
|
||||
picker.classList.remove('is-open-above');
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'false');
|
||||
}
|
||||
}
|
||||
|
||||
function openAnnouncementTypePicker() {
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
if (!picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
picker.hidden = false;
|
||||
if (toggle) {
|
||||
toggle.setAttribute('aria-expanded', 'true');
|
||||
}
|
||||
|
||||
if (typeof window !== 'undefined' && window.requestAnimationFrame) {
|
||||
window.requestAnimationFrame(positionAnnouncementTypePicker);
|
||||
} else {
|
||||
positionAnnouncementTypePicker();
|
||||
}
|
||||
}
|
||||
|
||||
function updateAnnouncementTypePickerSelection() {
|
||||
var typeInput = document.getElementById('announcement-type');
|
||||
var previewButton = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
var picker = document.querySelector('[data-announcement-type-picker]');
|
||||
if (!typeInput || !previewButton || !picker) {
|
||||
return;
|
||||
}
|
||||
|
||||
var selectedValue = String(typeInput.value || '').trim().toLowerCase();
|
||||
var selectedOption = picker.querySelector('[data-announcement-type-option][data-type-key="' + selectedValue + '"]');
|
||||
var label = selectedOption ? String(selectedOption.getAttribute('data-type-label') || selectedOption.textContent || selectedValue).trim() : selectedValue;
|
||||
var iconKey = selectedOption ? String(selectedOption.getAttribute('data-type-icon') || '').trim().toLowerCase() : '';
|
||||
var icon = previewButton.querySelector('[data-announcement-type-picker-icon]');
|
||||
var text = previewButton.querySelector('[data-announcement-type-picker-label]');
|
||||
|
||||
previewButton.setAttribute('aria-label', label);
|
||||
previewButton.setAttribute('title', label);
|
||||
if (icon) {
|
||||
icon.className = 'bi bi-' + iconKey;
|
||||
}
|
||||
if (text) {
|
||||
text.textContent = label;
|
||||
}
|
||||
|
||||
picker.querySelectorAll('[data-announcement-type-option]').forEach(function (button) {
|
||||
var isSelected = String(button.getAttribute('data-type-key') || '').trim().toLowerCase() === selectedValue;
|
||||
button.classList.toggle('is-selected', isSelected);
|
||||
button.setAttribute('aria-pressed', isSelected ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function setAnnouncementTypeValue(typeKey) {
|
||||
var typeInput = document.getElementById('announcement-type');
|
||||
if (!typeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = String(typeKey || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
|
||||
typeInput.value = normalized;
|
||||
updateAnnouncementTypePickerSelection();
|
||||
}
|
||||
|
||||
function updateAnnouncementIconPreview() {
|
||||
@@ -155,21 +261,90 @@ function setAnnouncementIconValue(iconKey) {
|
||||
}
|
||||
|
||||
function initAnnouncementForm() {
|
||||
var typeInput = document.getElementById('announcement-type');
|
||||
var typePickerToggle = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
var typePicker = document.querySelector('[data-announcement-type-picker]');
|
||||
var typePickerClose = document.querySelector('[data-announcement-type-picker-close]');
|
||||
var modeSelect = document.getElementById('announcement-duration-mode');
|
||||
var colorSelect = document.getElementById('announcement-color');
|
||||
var colorInput = document.getElementById('announcement-color');
|
||||
var colorPicker = document.querySelector('[data-announcement-color-picker-shell]');
|
||||
var screenSelectAll = document.querySelector('[data-announcement-screen-select-all]');
|
||||
var screenSelectNone = document.querySelector('[data-announcement-screen-select-none]');
|
||||
var iconSelect = document.getElementById('announcement-icon');
|
||||
var iconPickerToggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
var iconPicker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var iconPickerClose = document.querySelector('[data-announcement-icon-picker-close]');
|
||||
|
||||
if (typeInput) {
|
||||
updateAnnouncementTypePickerSelection();
|
||||
}
|
||||
|
||||
if (typePickerToggle) {
|
||||
typePickerToggle.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
var isExpanded = typePicker && !typePicker.hidden;
|
||||
if (isExpanded) {
|
||||
closeAnnouncementTypePicker();
|
||||
} else {
|
||||
openAnnouncementTypePicker();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
if (typePickerClose) {
|
||||
typePickerClose.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
closeAnnouncementTypePicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (typePicker) {
|
||||
typePicker.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-announcement-type-option]') : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
setAnnouncementTypeValue(button.getAttribute('data-type-key'));
|
||||
closeAnnouncementTypePicker();
|
||||
});
|
||||
}
|
||||
|
||||
if (modeSelect) {
|
||||
modeSelect.addEventListener('change', updateAnnouncementDurationFields);
|
||||
updateAnnouncementDurationFields();
|
||||
}
|
||||
|
||||
if (colorSelect) {
|
||||
colorSelect.addEventListener('change', updateAnnouncementColorPreview);
|
||||
updateAnnouncementColorPreview();
|
||||
if (colorInput) {
|
||||
updateAnnouncementColorPickerSelection();
|
||||
}
|
||||
|
||||
if (colorPicker) {
|
||||
colorPicker.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-announcement-color-option]') : null;
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
colorInput.value = String(button.getAttribute('data-color-key') || '').trim().toLowerCase();
|
||||
updateAnnouncementColorPickerSelection();
|
||||
});
|
||||
}
|
||||
|
||||
if (screenSelectAll) {
|
||||
screenSelectAll.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
setAnnouncementScreenSelection(true);
|
||||
});
|
||||
}
|
||||
|
||||
if (screenSelectNone) {
|
||||
screenSelectNone.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
setAnnouncementScreenSelection(false);
|
||||
});
|
||||
}
|
||||
|
||||
if (iconSelect) {
|
||||
@@ -210,8 +385,17 @@ function initAnnouncementForm() {
|
||||
}
|
||||
|
||||
window.addEventListener('resize', positionAnnouncementIconPicker);
|
||||
window.addEventListener('resize', positionAnnouncementTypePicker);
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var typePickerNode = document.querySelector('[data-announcement-type-picker]');
|
||||
var typeToggleNode = document.querySelector('[data-announcement-type-picker-toggle]');
|
||||
if (typePickerNode && !typePickerNode.hidden) {
|
||||
if (!(typePickerNode.contains(event.target) || (typeToggleNode && typeToggleNode.contains(event.target)))) {
|
||||
closeAnnouncementTypePicker();
|
||||
}
|
||||
}
|
||||
|
||||
var picker = document.querySelector('[data-announcement-icon-picker]');
|
||||
var toggle = document.querySelector('[data-announcement-icon-picker-toggle]');
|
||||
if (!picker || picker.hidden) {
|
||||
|
||||
@@ -10,6 +10,10 @@
|
||||
return;
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
form.dataset.dirty = 'true';
|
||||
}
|
||||
|
||||
function pad(value) {
|
||||
return String(value).padStart(2, '0');
|
||||
}
|
||||
@@ -95,6 +99,7 @@
|
||||
}
|
||||
button.addEventListener('click', function () {
|
||||
row.remove();
|
||||
markDirty();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -106,6 +111,7 @@
|
||||
}
|
||||
bindRemove(row);
|
||||
body.appendChild(fragment);
|
||||
markDirty();
|
||||
}
|
||||
|
||||
body.querySelectorAll('[data-timetable-entry-row]').forEach(function (row) {
|
||||
|
||||
@@ -197,6 +197,341 @@
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function parseLocalDate(value) {
|
||||
var date = new Date(String(value || '').trim());
|
||||
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate());
|
||||
}
|
||||
|
||||
function isDayInDateRange(startDatetime, endDatetime, dayIndex) {
|
||||
var startDate = parseLocalDate(startDatetime);
|
||||
var endDate = parseLocalDate(endDatetime);
|
||||
var currentDate;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (currentDate = new Date(startDate.getTime()); currentDate.getTime() <= endDate.getTime(); currentDate.setDate(currentDate.getDate() + 1)) {
|
||||
if (currentDate.getDay() === Number(dayIndex)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getTimeParts(value) {
|
||||
var match = String(value || '').trim().match(/^(\d{2}):(\d{2})(?::\d{2})?$/);
|
||||
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
hours: Number(match[1]),
|
||||
minutes: Number(match[2])
|
||||
};
|
||||
}
|
||||
|
||||
function combineDateAndTime(baseDate, timeValue) {
|
||||
var timeParts = getTimeParts(timeValue);
|
||||
|
||||
if (!baseDate || !timeParts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Date(
|
||||
baseDate.getFullYear(),
|
||||
baseDate.getMonth(),
|
||||
baseDate.getDate(),
|
||||
timeParts.hours,
|
||||
timeParts.minutes,
|
||||
0,
|
||||
0
|
||||
);
|
||||
}
|
||||
|
||||
function hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, selectedDays) {
|
||||
var startDateTime = new Date(String(startDatetime || '').trim());
|
||||
var endDateTime = new Date(String(endDatetime || '').trim());
|
||||
var startDate = parseLocalDate(startDatetime);
|
||||
var endDate = parseLocalDate(endDatetime);
|
||||
var allowedDays = Array.isArray(selectedDays) && selectedDays.length ? selectedDays : [0, 1, 2, 3, 4, 5, 6];
|
||||
var currentDate;
|
||||
|
||||
if (!startDate || !endDate || Number.isNaN(startDateTime.getTime()) || Number.isNaN(endDateTime.getTime())) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (currentDate = new Date(startDate.getTime()); currentDate.getTime() <= endDate.getTime(); currentDate.setDate(currentDate.getDate() + 1)) {
|
||||
if (allowedDays.indexOf(currentDate.getDay()) === -1) {
|
||||
continue;
|
||||
}
|
||||
|
||||
var dayStart = combineDateAndTime(currentDate, startTime);
|
||||
var dayEnd = combineDateAndTime(currentDate, endTime);
|
||||
|
||||
if (!dayStart || !dayEnd) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (dayEnd >= startDateTime && dayStart <= endDateTime) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function getDateRangeDayIndices(startDatetime, endDatetime) {
|
||||
var startDate = parseLocalDate(startDatetime);
|
||||
var endDate = parseLocalDate(endDatetime);
|
||||
var dayIndices = [];
|
||||
var currentDate;
|
||||
|
||||
if (!startDate || !endDate) {
|
||||
return dayIndices;
|
||||
}
|
||||
|
||||
for (currentDate = new Date(startDate.getTime()); currentDate.getTime() <= endDate.getTime(); currentDate.setDate(currentDate.getDate() + 1)) {
|
||||
if (dayIndices.indexOf(currentDate.getDay()) === -1) {
|
||||
dayIndices.push(currentDate.getDay());
|
||||
}
|
||||
}
|
||||
|
||||
return dayIndices;
|
||||
}
|
||||
|
||||
function setDayInputError(card, dayInput, message) {
|
||||
var dayInputs = card ? Array.prototype.slice.call(card.querySelectorAll('[data-schedule-rule-day]')) : [];
|
||||
var dayLabels = card ? Array.prototype.slice.call(card.querySelectorAll('.schedule-day-button')) : [];
|
||||
var feedbackNode = card ? card.querySelector('[data-schedule-rule-feedback]') : null;
|
||||
var dayIndex = dayInputs.indexOf(dayInput);
|
||||
|
||||
if (dayInput) {
|
||||
dayInput.setCustomValidity(message);
|
||||
dayInput.classList.add('is-invalid');
|
||||
dayInput.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
|
||||
if (dayIndex !== -1 && dayLabels[dayIndex]) {
|
||||
dayLabels[dayIndex].classList.add('is-invalid');
|
||||
dayLabels[dayIndex].setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
|
||||
if (feedbackNode) {
|
||||
var messageNode = document.createElement('div');
|
||||
messageNode.textContent = message;
|
||||
feedbackNode.appendChild(messageNode);
|
||||
feedbackNode.hidden = false;
|
||||
}
|
||||
}
|
||||
|
||||
function appendRuleFeedbackMessage(card, message) {
|
||||
var feedbackNode = card ? card.querySelector('[data-schedule-rule-feedback]') : null;
|
||||
|
||||
if (!feedbackNode) {
|
||||
return;
|
||||
}
|
||||
|
||||
var messageNode = document.createElement('div');
|
||||
messageNode.textContent = message;
|
||||
feedbackNode.appendChild(messageNode);
|
||||
feedbackNode.hidden = false;
|
||||
}
|
||||
|
||||
function validateScheduleRuleCard(card) {
|
||||
var fields = getRuleFields(card);
|
||||
var dayInputs = Array.prototype.slice.call(fields.days || []);
|
||||
var dayLabels = getRuleDayLabels(card);
|
||||
var checkedDayInputs = dayInputs.filter(function (input) {
|
||||
return Boolean(input && input.checked);
|
||||
});
|
||||
var checkedDayIndices = checkedDayInputs.map(function (input) {
|
||||
return Number(input && input.value);
|
||||
});
|
||||
var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim();
|
||||
var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim();
|
||||
var startTime = String(fields.startTime && fields.startTime.value || '').trim();
|
||||
var endTime = String(fields.endTime && fields.endTime.value || '').trim();
|
||||
clearRuleValidity(card);
|
||||
|
||||
if ((startDatetime || endDatetime) && (!startDatetime || !endDatetime)) {
|
||||
setRuleError(!startDatetime ? fields.startDatetime : fields.endDatetime, 'Start and end datetimes must both be set for this rule.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if ((startTime || endTime) && (!startTime || !endTime)) {
|
||||
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime)) {
|
||||
setRuleError(fields.endDatetime, 'End datetime must be after start datetime.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startTime && endTime && endTime <= startTime) {
|
||||
setRuleError(fields.endTime, 'End time must be after start time.');
|
||||
if (fields.startTime) {
|
||||
fields.startTime.classList.add('is-invalid');
|
||||
fields.startTime.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startDatetime && endDatetime) {
|
||||
var dateRangeDayIndices = getDateRangeDayIndices(startDatetime, endDatetime);
|
||||
var dayIndicesToCheck = checkedDayIndices.length ? checkedDayIndices : [0, 1, 2, 3, 4, 5, 6];
|
||||
var invalidDayInputs = checkedDayInputs.filter(function (input) {
|
||||
return dateRangeDayIndices.indexOf(Number(input && input.value)) === -1;
|
||||
});
|
||||
|
||||
invalidDayInputs.forEach(function (input) {
|
||||
var dayIndex = dayInputs.indexOf(input);
|
||||
|
||||
if (dayIndex !== -1 && dayLabels[dayIndex]) {
|
||||
dayLabels[dayIndex].classList.add('is-invalid');
|
||||
dayLabels[dayIndex].setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
if (checkedDayInputs.length && invalidDayInputs.length === checkedDayInputs.length) {
|
||||
setDayInputError(card, invalidDayInputs[0] || checkedDayInputs[0] || dayInputs[0], 'Selected days must overlap the date range.');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (startTime && endTime && !hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, dayIndicesToCheck)) {
|
||||
setRuleError(fields.endTime, 'Start and end times must overlap the date range.');
|
||||
if (fields.startTime) {
|
||||
fields.startTime.classList.add('is-invalid');
|
||||
fields.startTime.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function getScheduleRuleValidationIssue(card) {
|
||||
var fields = getRuleFields(card);
|
||||
var dayInputs = Array.prototype.slice.call(fields.days || []);
|
||||
var checkedDayInputs = dayInputs.filter(function (input) {
|
||||
return Boolean(input && input.checked);
|
||||
});
|
||||
var checkedDayIndices = checkedDayInputs.map(function (input) {
|
||||
return Number(input && input.value);
|
||||
});
|
||||
var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim();
|
||||
var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim();
|
||||
var startTime = String(fields.startTime && fields.startTime.value || '').trim();
|
||||
var endTime = String(fields.endTime && fields.endTime.value || '').trim();
|
||||
|
||||
if (startDatetime || endDatetime) {
|
||||
if (!startDatetime || !endDatetime) {
|
||||
return {
|
||||
type: 'datetime-pair',
|
||||
target: !startDatetime ? fields.startDatetime : fields.endDatetime,
|
||||
message: 'Start and end datetimes must both be set for this rule.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (startTime || endTime) {
|
||||
if (!startTime || !endTime) {
|
||||
return {
|
||||
type: 'time-pair',
|
||||
target: !startTime ? fields.startTime : fields.endTime,
|
||||
message: 'Start and end times must both be set for this rule.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime)) {
|
||||
return {
|
||||
type: 'datetime-order',
|
||||
target: fields.endDatetime,
|
||||
message: 'End datetime must be after start datetime.'
|
||||
};
|
||||
}
|
||||
|
||||
if (startTime && endTime && endTime <= startTime) {
|
||||
return {
|
||||
type: 'time-order',
|
||||
target: fields.endTime,
|
||||
message: 'End time must be after start time.'
|
||||
};
|
||||
}
|
||||
|
||||
if (startDatetime && endDatetime) {
|
||||
var dateRangeDayIndices = getDateRangeDayIndices(startDatetime, endDatetime);
|
||||
var dayIndicesToCheck = checkedDayIndices.length ? checkedDayIndices : [0, 1, 2, 3, 4, 5, 6];
|
||||
var invalidDayInputs = checkedDayInputs.filter(function (input) {
|
||||
return dateRangeDayIndices.indexOf(Number(input && input.value)) === -1;
|
||||
});
|
||||
|
||||
if (checkedDayInputs.length && invalidDayInputs.length === checkedDayInputs.length) {
|
||||
return {
|
||||
type: 'day-overlap',
|
||||
target: checkedDayInputs[0] || dayInputs[0] || fields.endDatetime,
|
||||
message: 'Selected days must overlap the date range.'
|
||||
};
|
||||
}
|
||||
|
||||
if (startTime && endTime && !hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, dayIndicesToCheck)) {
|
||||
return {
|
||||
type: 'time-overlap',
|
||||
target: fields.endTime,
|
||||
secondaryTarget: fields.startTime,
|
||||
message: 'Start and end times must overlap the date range.'
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyScheduleRuleValidationIssue(card, issue) {
|
||||
var fields = getRuleFields(card);
|
||||
var dayLabels = getRuleDayLabels(card);
|
||||
|
||||
clearRuleValidity(card);
|
||||
|
||||
if (!issue) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (issue.type === 'time-overlap') {
|
||||
setRuleError(issue.target || fields.endTime, issue.message);
|
||||
if (issue.secondaryTarget) {
|
||||
issue.secondaryTarget.classList.add('is-invalid');
|
||||
issue.secondaryTarget.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if (issue.type === 'day-overlap') {
|
||||
var dayInputs = Array.prototype.slice.call(fields.days || []);
|
||||
var dayIndex = dayInputs.indexOf(issue.target);
|
||||
|
||||
setRuleError(issue.target, issue.message);
|
||||
if (dayIndex !== -1 && dayLabels[dayIndex]) {
|
||||
dayLabels[dayIndex].classList.add('is-invalid');
|
||||
dayLabels[dayIndex].setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
setRuleError(issue.target, issue.message);
|
||||
return false;
|
||||
}
|
||||
|
||||
// Video duration helpers.
|
||||
function getVideoDurationButtonLabel(isPressed) {
|
||||
return isPressed ? 'Disable use video duration' : 'Use video duration';
|
||||
@@ -213,7 +548,7 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<button type="button" class="btn btn-outline-info btn-sm playlist-use-video-duration' + (isPressed ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '" aria-label="' + getVideoDurationButtonLabel(isPressed) + '" title="' + getVideoDurationButtonLabel(isPressed) + '">' + buildVideoDurationButtonContent(isPressed) + '</button>';
|
||||
return '<button type="button" class="btn ' + (isPressed ? 'btn-info' : 'btn-outline-info') + ' btn-sm playlist-use-video-duration" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '" aria-label="' + getVideoDurationButtonLabel(isPressed) + '" title="' + getVideoDurationButtonLabel(isPressed) + '">' + buildVideoDurationButtonContent(isPressed) + '</button>';
|
||||
}
|
||||
|
||||
function getDisableAudioButtonLabel(isPressed) {
|
||||
@@ -241,7 +576,6 @@
|
||||
|
||||
button.classList.toggle('btn-outline-secondary', Boolean(isPressed));
|
||||
button.classList.toggle('btn-secondary', !Boolean(isPressed));
|
||||
button.classList.toggle('active', !Boolean(isPressed));
|
||||
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
|
||||
button.setAttribute('aria-label', getDisableAudioButtonLabel(isPressed));
|
||||
button.setAttribute('title', getDisableAudioButtonLabel(isPressed));
|
||||
@@ -253,7 +587,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.toggle('active', Boolean(isPressed));
|
||||
button.classList.toggle('btn-outline-info', !Boolean(isPressed));
|
||||
button.classList.toggle('btn-info', Boolean(isPressed));
|
||||
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
|
||||
button.setAttribute('aria-label', getVideoDurationButtonLabel(isPressed));
|
||||
button.setAttribute('title', getVideoDurationButtonLabel(isPressed));
|
||||
@@ -382,7 +717,11 @@
|
||||
return '' +
|
||||
'<td class="playlist-order-cell" data-label="Order">' +
|
||||
'<div class="playlist-order-cell-inner">' +
|
||||
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
|
||||
'<div class="playlist-order-stepper" aria-label="Reorder slide controls">' +
|
||||
'<button type="button" class="playlist-order-move playlist-order-move-up btn btn-link p-0 text-body-secondary" data-playlist-row-move="up" aria-label="Move slide up" title="Move slide up"><i class="bi bi-caret-up-fill" aria-hidden="true" data-playlist-row-move-icon="up"></i></button>' +
|
||||
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><i class="bi bi-grip-horizontal"></i></span></button>' +
|
||||
'<button type="button" class="playlist-order-move playlist-order-move-down btn btn-link p-0 text-body-secondary" data-playlist-row-move="down" aria-label="Move slide down" title="Move slide down"><i class="bi bi-caret-down-fill" aria-hidden="true" data-playlist-row-move-icon="down"></i></button>' +
|
||||
'</div>' +
|
||||
'<span class="playlist-order-number"></span>' +
|
||||
'</div>' +
|
||||
'</td>';
|
||||
@@ -412,8 +751,11 @@
|
||||
|
||||
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
|
||||
return '' +
|
||||
'<td data-label="Config"><div class="playlist-duration-field">' +
|
||||
'<input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
|
||||
'<td data-label="Playback"><div class="playlist-duration-field">' +
|
||||
'<div class="input-group input-group-sm flex-nowrap playlist-duration-input-group">' +
|
||||
'<span class="input-group-text playlist-duration-prefix text-body-secondary" aria-hidden="true"><i class="bi bi-clock"></i></span>' +
|
||||
'<input name="duration_seconds[]" type="text" inputmode="decimal" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" data-limit-input-value' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="disable_audio[]" value="' + (values.disableAudio ? '1' : '0') + '" form="playlist-edit-form" data-playlist-disable-audio-input />' +
|
||||
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
|
||||
@@ -586,8 +928,7 @@
|
||||
'<div class="d-flex align-items-center gap-4 ms-auto flex-shrink-0">' +
|
||||
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove-schedule-rule data-confirm-message="Remove this rule?">Remove</button>' +
|
||||
'<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Collapse rule ' + String(ruleIndex) + '" title="Collapse rule ' + String(ruleIndex) + '">' +
|
||||
'<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>' +
|
||||
'<i class="bi ' + (isCollapsed ? 'bi-plus-lg' : 'bi-dash-lg') + '" aria-hidden="true" data-schedule-rule-collapse-icon></i>' +
|
||||
'<span class="visually-hidden">Collapse rule ' + String(ruleIndex) + '</span>' +
|
||||
'</button>' +
|
||||
'</div>' +
|
||||
@@ -614,11 +955,14 @@
|
||||
'</div>' +
|
||||
'<div class="col-12">' +
|
||||
'<div class="btn-group btn-group-sm w-100 schedule-day-grid" role="group" aria-label="Schedule days">' + DAY_NAMES.map(function (label, dayIndex) {
|
||||
return '<input class="btn-check" type="checkbox" id="schedule-rule-' + String(ruleIndex) + '-day-' + String(dayIndex) + '" value="' + String(dayIndex) + '" autocomplete="off"' + (selectedDays.has(dayIndex) ? ' checked' : '') + ' data-schedule-rule-day />' +
|
||||
'<label class="btn btn-outline-primary flex-fill schedule-day-button" for="schedule-rule-' + String(ruleIndex) + '-day-' + String(dayIndex) + '">' + label + '</label>';
|
||||
return '<span class="schedule-day-option">' +
|
||||
'<input class="btn-check" type="checkbox" id="schedule-rule-' + String(ruleIndex) + '-day-' + String(dayIndex) + '" value="' + String(dayIndex) + '" autocomplete="off"' + (selectedDays.has(dayIndex) ? ' checked' : '') + ' data-schedule-rule-day />' +
|
||||
'<label class="btn btn-outline-primary flex-fill schedule-day-button" for="schedule-rule-' + String(ruleIndex) + '-day-' + String(dayIndex) + '">' + label + '</label>' +
|
||||
'</span>';
|
||||
}).join('') + '</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<div class="invalid-feedback d-block mt-2" data-schedule-rule-feedback aria-live="polite" hidden></div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
@@ -779,13 +1123,71 @@
|
||||
return card ? card.querySelector('[data-remove-schedule-rule]') : null;
|
||||
}
|
||||
|
||||
function getRuleDayLabels(card) {
|
||||
return card ? Array.prototype.slice.call(card.querySelectorAll('.schedule-day-button')) : [];
|
||||
}
|
||||
|
||||
function getCollapseRuleButton(card) {
|
||||
return card ? card.querySelector('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null;
|
||||
}
|
||||
|
||||
function getCollapseRuleIcon(card) {
|
||||
return card ? card.querySelector('[data-schedule-rule-collapse-icon]') : null;
|
||||
}
|
||||
|
||||
function syncRuleCollapseButtonState(card) {
|
||||
var button = getCollapseRuleButton(card);
|
||||
var icon = getCollapseRuleIcon(card);
|
||||
var numberNode = getRuleNumberNode(card);
|
||||
var ruleIndex = numberNode ? String(numberNode.textContent || '') : '';
|
||||
var isCollapsed = Boolean(card && card.classList && card.classList.contains('collapsed-card'));
|
||||
var label = (isCollapsed ? 'Expand' : 'Collapse') + ' rule ' + ruleIndex;
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.setAttribute('aria-expanded', isCollapsed ? 'false' : 'true');
|
||||
button.setAttribute('aria-label', label);
|
||||
button.setAttribute('title', label);
|
||||
|
||||
if (icon) {
|
||||
icon.classList.remove('bi-dash-lg', 'bi-plus-lg');
|
||||
icon.classList.add(isCollapsed ? 'bi-plus-lg' : 'bi-dash-lg');
|
||||
}
|
||||
|
||||
var visibleLabel = button.querySelector('.visually-hidden');
|
||||
if (visibleLabel) {
|
||||
visibleLabel.textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function clearRuleValidity(card) {
|
||||
var fields = getRuleFields(card);
|
||||
var dayInputs = Array.prototype.slice.call(fields.days || []);
|
||||
var dayLabels = getRuleDayLabels(card);
|
||||
var feedbackNode = card ? card.querySelector('[data-schedule-rule-feedback]') : null;
|
||||
[fields.startDatetime, fields.endDatetime, fields.startTime, fields.endTime].forEach(function (input) {
|
||||
if (input) {
|
||||
input.setCustomValidity('');
|
||||
input.classList.remove('is-invalid');
|
||||
input.removeAttribute('aria-invalid');
|
||||
}
|
||||
});
|
||||
dayInputs.forEach(function (input) {
|
||||
input.setCustomValidity('');
|
||||
input.classList.remove('is-invalid');
|
||||
input.removeAttribute('aria-invalid');
|
||||
});
|
||||
dayLabels.forEach(function (label) {
|
||||
label.classList.remove('is-invalid');
|
||||
label.removeAttribute('aria-invalid');
|
||||
});
|
||||
|
||||
if (feedbackNode) {
|
||||
feedbackNode.innerHTML = '';
|
||||
feedbackNode.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
function setRuleError(input, message) {
|
||||
@@ -793,37 +1195,96 @@
|
||||
return;
|
||||
}
|
||||
input.setCustomValidity(message);
|
||||
if (String(input.value || '').trim()) {
|
||||
input.classList.add('is-invalid');
|
||||
input.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
|
||||
appendRuleFeedbackMessage(input.closest ? input.closest('[data-schedule-rule-card]') : null, message);
|
||||
}
|
||||
|
||||
function validateRuleCard(card) {
|
||||
function validateScheduleRuleCard(card) {
|
||||
var fields = getRuleFields(card);
|
||||
var dayInputs = Array.prototype.slice.call(fields.days || []);
|
||||
var dayLabels = getRuleDayLabels(card);
|
||||
var checkedDayInputs = dayInputs.filter(function (input) {
|
||||
return Boolean(input && input.checked);
|
||||
});
|
||||
var checkedDayIndices = checkedDayInputs.map(function (input) {
|
||||
return Number(input && input.value);
|
||||
});
|
||||
var startDatetime = String(fields.startDatetime && fields.startDatetime.value || '').trim();
|
||||
var endDatetime = String(fields.endDatetime && fields.endDatetime.value || '').trim();
|
||||
var startTime = String(fields.startTime && fields.startTime.value || '').trim();
|
||||
var endTime = String(fields.endTime && fields.endTime.value || '').trim();
|
||||
var hasDateInput = Boolean(startDatetime || endDatetime);
|
||||
var hasTimeInput = Boolean(startTime || endTime);
|
||||
var isValid = true;
|
||||
|
||||
clearRuleValidity(card);
|
||||
|
||||
if (hasDateInput && (!startDatetime || !endDatetime)) {
|
||||
setRuleError(fields.startDatetime || fields.endDatetime, 'Start and end datetimes must both be set for this rule.');
|
||||
return false;
|
||||
}
|
||||
if (hasTimeInput && (!startTime || !endTime)) {
|
||||
setRuleError(fields.startTime || fields.endTime, 'Start and end times must both be set for this rule.');
|
||||
return false;
|
||||
}
|
||||
if (startDatetime && endDatetime && new Date(endDatetime) < new Date(startDatetime)) {
|
||||
setRuleError(fields.endDatetime, 'End datetime must be after start datetime.');
|
||||
return false;
|
||||
}
|
||||
if (startTime && endTime && endTime < startTime) {
|
||||
setRuleError(fields.endTime, 'End time must be after start time.');
|
||||
return false;
|
||||
if ((startDatetime || endDatetime) && (!startDatetime || !endDatetime)) {
|
||||
setRuleError(!startDatetime ? fields.startDatetime : fields.endDatetime, 'Start and end datetimes must both be set for this rule.');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
if ((startTime || endTime) && (!startTime || !endTime)) {
|
||||
setRuleError(!startTime ? fields.startTime : fields.endTime, 'Start and end times must both be set for this rule.');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (startDatetime && endDatetime && new Date(endDatetime) <= new Date(startDatetime)) {
|
||||
setRuleError(fields.endDatetime, 'End datetime must be after start datetime.');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (startTime && endTime && endTime <= startTime) {
|
||||
setRuleError(fields.endTime, 'End time must be after start time.');
|
||||
if (fields.startTime) {
|
||||
fields.startTime.classList.add('is-invalid');
|
||||
fields.startTime.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (startDatetime && endDatetime) {
|
||||
var dateRangeDayIndices = getDateRangeDayIndices(startDatetime, endDatetime);
|
||||
var dayIndicesToCheck = checkedDayIndices.length ? checkedDayIndices : [0, 1, 2, 3, 4, 5, 6];
|
||||
var invalidDayInputs = checkedDayInputs.filter(function (input) {
|
||||
return dateRangeDayIndices.indexOf(Number(input && input.value)) === -1;
|
||||
});
|
||||
|
||||
invalidDayInputs.forEach(function (input) {
|
||||
var dayIndex = dayInputs.indexOf(input);
|
||||
|
||||
if (dayIndex !== -1 && dayLabels[dayIndex]) {
|
||||
dayLabels[dayIndex].classList.add('is-invalid');
|
||||
dayLabels[dayIndex].setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
});
|
||||
|
||||
if (checkedDayInputs.length && invalidDayInputs.length === checkedDayInputs.length) {
|
||||
setDayInputError(card, checkedDayInputs[0] || dayInputs[0], 'Selected days must overlap the date range.');
|
||||
isValid = false;
|
||||
}
|
||||
|
||||
if (startTime && endTime && !hasScheduleRuleOverlap(startDatetime, endDatetime, startTime, endTime, dayIndicesToCheck)) {
|
||||
setRuleError(fields.endTime, 'Start and end times must overlap the date range.');
|
||||
if (fields.startTime) {
|
||||
fields.startTime.classList.add('is-invalid');
|
||||
fields.startTime.setAttribute('aria-invalid', 'true');
|
||||
}
|
||||
isValid = false;
|
||||
}
|
||||
}
|
||||
|
||||
return isValid;
|
||||
}
|
||||
|
||||
function updateLiveRuleValidity(card) {
|
||||
validateScheduleRuleCard(card);
|
||||
}
|
||||
|
||||
function validateRuleCard(card) {
|
||||
return validateScheduleRuleCard(card);
|
||||
}
|
||||
|
||||
function readRuleFromCard(card) {
|
||||
@@ -978,37 +1439,47 @@
|
||||
card.querySelector('[data-schedule-rule-start-time]').value = String(normalizedRule.start_time || '');
|
||||
card.querySelector('[data-schedule-rule-end-time]').value = String(normalizedRule.end_time || '');
|
||||
card.querySelector('[data-remove-schedule-rule]').setAttribute('data-confirm-message', 'Remove this rule?');
|
||||
card.querySelector('[data-lte-toggle="card-collapse"]').setAttribute('aria-label', 'Collapse rule ' + String(ruleIndex));
|
||||
card.querySelector('[data-lte-toggle="card-collapse"]').setAttribute('title', 'Collapse rule ' + String(ruleIndex));
|
||||
card.querySelector('[data-lte-toggle="card-collapse"] .visually-hidden').textContent = 'Collapse rule ' + String(ruleIndex);
|
||||
updateRuleCardDayOptions(card, ruleKey, normalizedRule);
|
||||
|
||||
card.querySelector('[data-lte-toggle="card-collapse"]').addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
card.classList.toggle('collapsed-card');
|
||||
persistScheduleDraft();
|
||||
});
|
||||
|
||||
if (isCollapsed) {
|
||||
card.classList.add('collapsed-card');
|
||||
} else {
|
||||
card.classList.remove('collapsed-card');
|
||||
}
|
||||
|
||||
try {
|
||||
updateLiveRuleValidity(card);
|
||||
} catch (_error) {
|
||||
clearRuleValidity(card);
|
||||
}
|
||||
syncRuleCollapseButtonState(card);
|
||||
|
||||
return card;
|
||||
}
|
||||
|
||||
function addRule(rule, options) {
|
||||
var ruleCount = getRuleCards().length + 1;
|
||||
var card = createRuleCardElement(rule || {}, ruleCount, options);
|
||||
scheduleRuleList.appendChild(card);
|
||||
|
||||
if (!card) {
|
||||
scheduleRuleList.insertAdjacentHTML('beforeend', buildScheduleRuleCardMarkup(rule || {}, ruleCount, Boolean(options && options.isCollapsed)));
|
||||
card = scheduleRuleList.lastElementChild;
|
||||
}
|
||||
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (card.parentNode !== scheduleRuleList) {
|
||||
scheduleRuleList.appendChild(card);
|
||||
}
|
||||
refreshRuleNumbers();
|
||||
syncScheduleRulesField();
|
||||
return card;
|
||||
}
|
||||
|
||||
function handleCardChange(card) {
|
||||
clearRuleValidity(card);
|
||||
updateLiveRuleValidity(card);
|
||||
updateRuleCardSummary(card);
|
||||
syncScheduleRulesField();
|
||||
}
|
||||
@@ -1074,6 +1545,16 @@
|
||||
|
||||
scheduleRuleList.addEventListener('click', function (event) {
|
||||
var removeButton = event.target && event.target.closest ? event.target.closest('[data-remove-schedule-rule]') : null;
|
||||
var collapseButton = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null;
|
||||
|
||||
if (collapseButton) {
|
||||
window.setTimeout(function () {
|
||||
syncRuleCollapseButtonState(collapseButton.closest('[data-schedule-rule-card]'));
|
||||
persistScheduleDraft();
|
||||
}, 0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!removeButton) {
|
||||
return;
|
||||
}
|
||||
@@ -1107,6 +1588,8 @@
|
||||
} else {
|
||||
refreshRuleNumbers();
|
||||
getRuleCards().forEach(function (card) {
|
||||
syncRuleCollapseButtonState(card);
|
||||
updateLiveRuleValidity(card);
|
||||
updateRuleCardSummary(card);
|
||||
});
|
||||
syncScheduleRulesField();
|
||||
@@ -1637,9 +2120,36 @@
|
||||
var rows = getRows();
|
||||
rows.forEach(function (row, index) {
|
||||
var orderNumber = row.querySelector('.playlist-order-number');
|
||||
var moveUpButton = row.querySelector('[data-playlist-row-move="up"]');
|
||||
var moveDownButton = row.querySelector('[data-playlist-row-move="down"]');
|
||||
var moveUpIcon = row.querySelector('[data-playlist-row-move-icon="up"]');
|
||||
var moveDownIcon = row.querySelector('[data-playlist-row-move-icon="down"]');
|
||||
var isFirst = index === 0;
|
||||
var isLast = index === rows.length - 1;
|
||||
|
||||
if (orderNumber) {
|
||||
orderNumber.textContent = String(index + 1);
|
||||
}
|
||||
|
||||
if (moveUpButton) {
|
||||
moveUpButton.disabled = isFirst;
|
||||
moveUpButton.setAttribute('aria-disabled', isFirst ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (moveUpIcon) {
|
||||
moveUpIcon.classList.toggle('bi-caret-up-fill', !isFirst);
|
||||
moveUpIcon.classList.toggle('bi-caret-up', isFirst);
|
||||
}
|
||||
|
||||
if (moveDownButton) {
|
||||
moveDownButton.disabled = isLast;
|
||||
moveDownButton.setAttribute('aria-disabled', isLast ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (moveDownIcon) {
|
||||
moveDownIcon.classList.toggle('bi-caret-down-fill', !isLast);
|
||||
moveDownIcon.classList.toggle('bi-caret-down', isLast);
|
||||
}
|
||||
});
|
||||
syncAddSlideOptions();
|
||||
syncPlaylistCanvasLock();
|
||||
@@ -1665,6 +2175,38 @@
|
||||
});
|
||||
}
|
||||
|
||||
function movePlaylistRowByOffset(row, offset) {
|
||||
var currentTbody = document.getElementById('playlist-items-body') || tbody;
|
||||
var rows = getRows();
|
||||
var currentIndex = rows.indexOf(row);
|
||||
var targetIndex;
|
||||
var referenceRow;
|
||||
|
||||
if (!currentTbody || !row || currentIndex === -1) {
|
||||
return false;
|
||||
}
|
||||
|
||||
targetIndex = currentIndex + Number(offset || 0);
|
||||
if (targetIndex < 0 || targetIndex >= rows.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
referenceRow = rows[targetIndex];
|
||||
|
||||
if (targetIndex > currentIndex) {
|
||||
referenceRow = referenceRow ? referenceRow.nextSibling : null;
|
||||
}
|
||||
|
||||
if (referenceRow) {
|
||||
currentTbody.insertBefore(row, referenceRow);
|
||||
} else {
|
||||
currentTbody.appendChild(row);
|
||||
}
|
||||
|
||||
updateRowOrder(true);
|
||||
return true;
|
||||
}
|
||||
|
||||
function populatePlaylistSlideRow(row, values, rowKey) {
|
||||
var canvasWidth = Number(values.canvas_width);
|
||||
var canvasHeight = Number(values.canvas_height);
|
||||
@@ -1898,6 +2440,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var moveButton = event.target.closest('[data-playlist-row-move]');
|
||||
var removeButton = event.target.closest('[data-playlist-remove-row]');
|
||||
var durationButton = event.target.closest('.playlist-use-video-duration');
|
||||
var muteButton = event.target.closest('.playlist-disable-audio');
|
||||
@@ -1907,6 +2450,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (moveButton) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
movePlaylistRowByOffset(row, moveButton.getAttribute('data-playlist-row-move') === 'up' ? -1 : 1);
|
||||
return;
|
||||
}
|
||||
|
||||
if (removeButton) {
|
||||
event.preventDefault();
|
||||
row.remove();
|
||||
|
||||
@@ -1,8 +1,16 @@
|
||||
// RBAC permission matrix helpers for checkbox groups.
|
||||
|
||||
(function () {
|
||||
function getGroupCheckboxes(group) {
|
||||
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
||||
function getPermissionSections() {
|
||||
return Array.prototype.slice.call(document.querySelectorAll('[data-permission-section]'));
|
||||
}
|
||||
|
||||
function getPermissionRows(container) {
|
||||
return Array.prototype.slice.call(container.querySelectorAll('[data-permission-row]'));
|
||||
}
|
||||
|
||||
function getRowCheckboxes(row) {
|
||||
return Array.prototype.slice.call(row.querySelectorAll('input[name="permission_keys[]"]'));
|
||||
}
|
||||
|
||||
function getPermissionKey(checkbox) {
|
||||
@@ -19,8 +27,12 @@
|
||||
return String(parts[1] || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function syncPermissionGroup(group) {
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
function syncPermissionRow(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
@@ -55,19 +67,57 @@
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupChange(event) {
|
||||
function setRowCheckboxes(row, checked) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
checkboxes.forEach(function (checkbox) {
|
||||
checkbox.checked = Boolean(checked);
|
||||
});
|
||||
|
||||
syncPermissionRow(row);
|
||||
}
|
||||
|
||||
function toggleRowCheckboxes(row) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var allChecked = checkboxes.every(function (checkbox) {
|
||||
return Boolean(checkbox.checked);
|
||||
});
|
||||
|
||||
setRowCheckboxes(row, !allChecked);
|
||||
}
|
||||
|
||||
function findPermissionRow(targetId) {
|
||||
return document.querySelector('[data-permission-row-id="' + targetId + '"]');
|
||||
}
|
||||
|
||||
function handleRowChange(event) {
|
||||
var checkbox = event.target && event.target.matches ? event.target : null;
|
||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||
return;
|
||||
}
|
||||
|
||||
var actionKey = getActionKey(checkbox);
|
||||
var group = checkbox.closest('.accordion-item');
|
||||
if (!group) {
|
||||
var row = checkbox.closest('[data-permission-row]');
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
var checkboxes = getRowCheckboxes(row);
|
||||
var readCheckbox = checkboxes.find(function (candidate) {
|
||||
return getActionKey(candidate) === 'read';
|
||||
}) || null;
|
||||
@@ -91,8 +141,28 @@
|
||||
}
|
||||
|
||||
function initPermissionGroups() {
|
||||
document.querySelectorAll('.accordion-item').forEach(function (group) {
|
||||
syncPermissionGroup(group);
|
||||
getPermissionSections().forEach(function (section) {
|
||||
getPermissionRows(section).forEach(function (row) {
|
||||
syncPermissionRow(row);
|
||||
});
|
||||
});
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var control = event.target && event.target.closest ? event.target.closest('[data-permission-row-toggle]') : null;
|
||||
if (!control) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
var targetId = String(control.getAttribute('data-permission-row-target') || '').trim();
|
||||
if (!targetId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (control.hasAttribute('data-permission-row-toggle')) {
|
||||
toggleRowCheckboxes(findPermissionRow(targetId));
|
||||
}
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
@@ -104,8 +174,8 @@
|
||||
return;
|
||||
}
|
||||
|
||||
handleGroupChange(event);
|
||||
syncPermissionGroup(checkbox.closest('.accordion-item'));
|
||||
handleRowChange(event);
|
||||
syncPermissionRow(checkbox.closest('[data-permission-row]'));
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -58,13 +58,26 @@
|
||||
return registry[normalizeType(type)] || null;
|
||||
}
|
||||
|
||||
function getRegionTypeLabel(type, definition) {
|
||||
return String(definition && definition.label ? definition.label : type || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
}
|
||||
|
||||
function compareRegionTypeEntries(left, right) {
|
||||
var leftLabel = getRegionTypeLabel(left && left.type, left && left.definition);
|
||||
var rightLabel = getRegionTypeLabel(right && right.type, right && right.definition);
|
||||
|
||||
return leftLabel.localeCompare(rightLabel, undefined, { sensitivity: 'base' }) || String(left && left.type || '').localeCompare(String(right && right.type || ''), undefined, { sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function list() {
|
||||
return Object.keys(registry).map(function (type) {
|
||||
return {
|
||||
type: type,
|
||||
definition: registry[type] || {}
|
||||
};
|
||||
});
|
||||
}).sort(compareRegionTypeEntries);
|
||||
}
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
@@ -91,18 +104,38 @@
|
||||
return String(definition.label);
|
||||
}
|
||||
|
||||
var normalized = normalizeType(regionType);
|
||||
if (!normalized) {
|
||||
return 'Region';
|
||||
}
|
||||
return 'Text';
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(/[_-]+/)
|
||||
.filter(function (segment) { return segment; })
|
||||
.map(function (segment) {
|
||||
return segment.charAt(0).toUpperCase() + segment.slice(1);
|
||||
})
|
||||
.join(' ') || 'Region';
|
||||
function escapeHtml(value) {
|
||||
return String(value === undefined || value === null ? '' : value)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function renderEditorCardShell(options) {
|
||||
var settings = options || {};
|
||||
var region = settings.region || {};
|
||||
var cardClass = String(settings.cardClass || 'card card-outline card-secondary admin-form-card template-field-card mb-3');
|
||||
var cardAttrs = String(settings.cardAttrs || '');
|
||||
var cardStyle = settings.cardStyle ? ' style="' + escapeHtml(settings.cardStyle) + '"' : '';
|
||||
var bodyClass = String(settings.bodyClass || 'card-body p-3 d-grid');
|
||||
var bodyAttrs = String(settings.bodyAttrs || '');
|
||||
var bodyStyle = settings.bodyStyle ? ' style="' + escapeHtml(settings.bodyStyle) + '"' : '';
|
||||
var headerActions = String(settings.headerActions || '');
|
||||
var bodyHtml = String(settings.bodyHtml || '');
|
||||
|
||||
return '' +
|
||||
'<div class="' + cardClass + '" data-region-id="' + escapeHtml(region.id) + '"' + cardAttrs + cardStyle + '>' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<div class="template-field-actions">' + headerActions + '</div>' +
|
||||
'</div>' +
|
||||
'<div class="' + bodyClass + '"' + bodyAttrs + bodyStyle + '>' + bodyHtml + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
root.pulseRegionTypes = {
|
||||
@@ -111,6 +144,7 @@
|
||||
list: list,
|
||||
getDefaultRegionSize: getDefaultRegionSize,
|
||||
getDefaultRegionStyle: getDefaultRegionStyle,
|
||||
getRegionChipLabel: getRegionChipLabel
|
||||
getRegionChipLabel: getRegionChipLabel,
|
||||
renderEditorCardShell: renderEditorCardShell
|
||||
};
|
||||
}());
|
||||
@@ -178,12 +178,25 @@
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute('data-remove-region-qr-image')) {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute('data-remove-region-image')) {
|
||||
return 'image';
|
||||
}
|
||||
|
||||
if (card && card.dataset && String(card.dataset.regionMediaType || '').trim()) {
|
||||
return String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
var regionMediaType = String(card.dataset.regionMediaType || '').trim();
|
||||
if (regionMediaType === 'video') {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (regionMediaType === 'qr-image') {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
return 'image';
|
||||
}
|
||||
|
||||
return 'image';
|
||||
@@ -194,7 +207,15 @@
|
||||
return callbacks.getExistingMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'existing_region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'existing_region_qr_image_';
|
||||
}
|
||||
|
||||
return 'existing_region_image_';
|
||||
}
|
||||
|
||||
function getMediaPrefix(mediaType) {
|
||||
@@ -202,7 +223,15 @@
|
||||
return callbacks.getMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'region_qr_image_';
|
||||
}
|
||||
|
||||
return 'region_image_';
|
||||
}
|
||||
|
||||
function getVideoDurationHiddenInput(regionId) {
|
||||
@@ -214,18 +243,33 @@
|
||||
}
|
||||
|
||||
function handleRemove(button) {
|
||||
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video');
|
||||
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video') || button.getAttribute('data-remove-region-qr-image');
|
||||
var card = getCard(regionId);
|
||||
var mediaType = getMediaType(button, card);
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingPrefix(mediaType) + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="' + getMediaPrefix(mediaType) + regionId + '"]');
|
||||
var hiddenName = getExistingPrefix(mediaType) + regionId;
|
||||
var inputName = getMediaPrefix(mediaType) + regionId;
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + hiddenName + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="' + inputName + '"]');
|
||||
|
||||
if (!hidden && regionId) {
|
||||
hidden = templateFields.querySelector('input[type="hidden"][name="' + hiddenName + '"]');
|
||||
}
|
||||
|
||||
if (!input && regionId) {
|
||||
input = templateFields.querySelector('input[type="file"][name="' + inputName + '"]');
|
||||
}
|
||||
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||
var hasPendingUpload = input && input.dataset && String(input.dataset.uploadToken || '').trim();
|
||||
|
||||
if (input && input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
|
||||
if (input && hasPendingUpload) {
|
||||
input.dataset.uploadCancelRequested = '1';
|
||||
}
|
||||
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath && typeof callbacks.queueUploadCleanup === 'function') {
|
||||
callbacks.queueUploadCleanup([uploadPath]);
|
||||
}
|
||||
@@ -238,6 +282,7 @@
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = '';
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
var durationHidden = getVideoDurationHiddenInput(regionId);
|
||||
@@ -259,7 +304,7 @@
|
||||
}
|
||||
|
||||
templateFields.addEventListener('click', function (event) {
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video]') : null;
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video], [data-remove-region-qr-image]') : null;
|
||||
|
||||
if (!button || !templateFields.contains(button)) {
|
||||
return;
|
||||
@@ -273,7 +318,7 @@
|
||||
return;
|
||||
}
|
||||
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video]') : null;
|
||||
var button = event.target && event.target.closest ? event.target.closest('[data-remove-region-image], [data-remove-region-video], [data-remove-region-qr-image]') : null;
|
||||
|
||||
if (!button || !templateFields.contains(button)) {
|
||||
return;
|
||||
@@ -304,7 +349,15 @@
|
||||
return callbacks.getMediaTypeFromInput(input);
|
||||
}
|
||||
|
||||
return input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
|
||||
if (input && input.name && input.name.indexOf('region_video_') === 0) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
if (input && input.name && input.name.indexOf('region_qr_image_') === 0) {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
return 'image';
|
||||
}
|
||||
|
||||
function getMediaPrefix(mediaType) {
|
||||
@@ -312,7 +365,15 @@
|
||||
return callbacks.getMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'region_qr_image_';
|
||||
}
|
||||
|
||||
return 'region_image_';
|
||||
}
|
||||
|
||||
function getExistingMediaPrefix(mediaType) {
|
||||
@@ -320,7 +381,15 @@
|
||||
return callbacks.getExistingMediaPrefix(mediaType);
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
if (String(mediaType || '').trim() === 'video') {
|
||||
return 'existing_region_video_';
|
||||
}
|
||||
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'existing_region_qr_image_';
|
||||
}
|
||||
|
||||
return 'existing_region_image_';
|
||||
}
|
||||
|
||||
function getUploadZone(input) {
|
||||
@@ -440,7 +509,7 @@
|
||||
}
|
||||
|
||||
function getPendingUploadCleanupPaths() {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]')).map(function (input) {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]')).map(function (input) {
|
||||
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
||||
return null;
|
||||
}
|
||||
@@ -450,7 +519,7 @@
|
||||
}
|
||||
|
||||
function clearPendingUploadCleanupPaths() {
|
||||
Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]')).forEach(function (input) {
|
||||
Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]')).forEach(function (input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
@@ -465,7 +534,7 @@
|
||||
}
|
||||
|
||||
var mediaType = getMediaTypeFromInput(input);
|
||||
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
|
||||
var regionId = input.name.replace(/^region_(?:image|video|qr_image)_/, '');
|
||||
var card = getCard(regionId);
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||
var zone = getUploadZone(input);
|
||||
@@ -475,6 +544,7 @@
|
||||
var uploadUrl = String(callbacks.uploadUrl || '/slides/uploads');
|
||||
var uploadTimeoutMs = Math.max(1, Number(callbacks.uploadTimeoutMs || 30000));
|
||||
|
||||
delete input.dataset.uploadCancelRequested;
|
||||
input.dataset.uploadToken = uploadToken;
|
||||
setUploadState(input, true, 0, 'Uploading...', true);
|
||||
|
||||
@@ -549,8 +619,19 @@
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
|
||||
if (String(input.dataset.uploadCancelRequested || '') === '1') {
|
||||
if (uploadedPath) {
|
||||
queueUploadCleanup([uploadedPath]);
|
||||
}
|
||||
|
||||
setUploadState(input, false);
|
||||
resolve(payload);
|
||||
return;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = uploadedPath;
|
||||
hidden.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
input.dataset.uploadedPath = uploadedPath;
|
||||
input.dataset.uploadedNeedsCleanup = '1';
|
||||
@@ -611,6 +692,7 @@
|
||||
xhr.onloadend = function () {
|
||||
if (input.dataset.uploadToken === uploadToken) {
|
||||
delete input.dataset.uploadToken;
|
||||
delete input.dataset.uploadCancelRequested;
|
||||
setUploadState(input, false);
|
||||
}
|
||||
};
|
||||
@@ -647,7 +729,7 @@
|
||||
templateFields.dataset.regionMediaUploadBound = '1';
|
||||
|
||||
templateFields.addEventListener('change', function (event) {
|
||||
var input = event.target && event.target.matches && event.target.matches('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]') ? event.target : null;
|
||||
var input = event.target && event.target.matches && event.target.matches('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]') ? event.target : null;
|
||||
|
||||
if (!input || !templateFields.contains(input)) {
|
||||
return;
|
||||
|
||||
@@ -232,7 +232,6 @@
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use transforms like {{name.upper()}}, {{name.title()}}, or {{name.lower()}} on leaf fields.</div>' +
|
||||
'<div class="api-region-placeholder-section">' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + ((placeholderFields.length && window.placeholderChips && typeof window.placeholderChips.renderChips === 'function') ? window.placeholderChips.renderChips(placeholderFields) : placeholderChips || '<span class="muted slide-image-file">No JSON fields available.</span>') + '</div>' +
|
||||
|
||||
@@ -20,19 +20,13 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">HTML</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">HTML content' +
|
||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">HTML</span>',
|
||||
bodyHtml: '' +
|
||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>'
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('html', {
|
||||
|
||||
@@ -34,47 +34,44 @@
|
||||
var uploadMaxLabel = String(uploadConfig.limitLabel || context.uploadMaxLabel || '100 MB');
|
||||
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'image/png, image/jpeg, image/gif, image/webp');
|
||||
var uploadHelpText = String(uploadConfig.helpText || 'PNG, JPG, GIF, or WebP');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="image">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Image</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">Image</span>',
|
||||
cardAttrs: ' data-region-media-type="image"',
|
||||
bodyHtml: '' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />'
|
||||
});
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -55,6 +55,11 @@
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getFeedItemCount(feedId, feeds) {
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
return feed && Array.isArray(feed.items) ? feed.items.length : 0;
|
||||
}
|
||||
|
||||
function getFieldList(feedId, feeds) {
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
var sampleItem = feed && Array.isArray(feed.items) ? feed.items[0] : null;
|
||||
@@ -128,7 +133,8 @@
|
||||
var feed = getFeedById(feedId, feeds);
|
||||
var items = feed && Array.isArray(feed.items) ? feed.items : [];
|
||||
var parsedItemNumber = Math.max(1, Number(itemNumber || 1));
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? parsedItemNumber - 1 : 0;
|
||||
var itemCount = Math.max(1, items.length || 1);
|
||||
var index = Number.isFinite(parsedItemNumber) && parsedItemNumber > 0 ? Math.min(itemCount, parsedItemNumber) - 1 : 0;
|
||||
return items[index] || null;
|
||||
}
|
||||
|
||||
@@ -208,6 +214,7 @@
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var itemNumberLimit = Math.max(1, Number(context.itemNumberLimit || 1));
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var feedOptions = String(context.feedOptions || '');
|
||||
return '' +
|
||||
@@ -232,15 +239,15 @@
|
||||
'</div>' +
|
||||
'<div class="col-6 col-md-4">' +
|
||||
'<label class="form-label" for="region_rss_item_number_' + region.id + '">Entry number</label>' +
|
||||
'<input id="region_rss_item_number_' + region.id + '" type="number" min="1" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'<input id="region_rss_item_number_' + region.id + '" type="number" min="1" max="' + escapeHtml(itemNumberLimit) + '" step="1" name="region_rss_item_number_' + region.id + '" class="form-control" value="' + escapeHtml(config.item_number) + '" />' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(context.fontSize || '') + '" />' +
|
||||
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current.value !== undefined ? current.value : '') + '" />' +
|
||||
'<div class="muted slide-image-file">Use transforms like {{title.upper()}}, {{title.title()}}, or {{title.lower()}} on leaf fields.</div>' +
|
||||
'<div class="api-region-placeholder-section">' +
|
||||
'<div class="api-region-placeholder-title">Available placeholders</div>' +
|
||||
'<div class="d-flex flex-wrap gap-2" data-placeholder-chips>' + (placeholderChips || '<span class="muted slide-image-file">No RSS fields available.</span>') + '</div>' +
|
||||
'<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, or <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
@@ -250,6 +257,7 @@
|
||||
var region = context.region;
|
||||
var current = context.current || {};
|
||||
var config = context.config || {};
|
||||
var itemNumberLimit = Math.max(1, Number(getFeedItemCount(config.feed_id, feeds) || 1));
|
||||
var placeholderChips = String(context.placeholderChips || '');
|
||||
var feedOptions = String(context.feedOptions || '');
|
||||
|
||||
@@ -257,6 +265,7 @@
|
||||
region: region,
|
||||
current: current,
|
||||
config: config,
|
||||
itemNumberLimit: itemNumberLimit,
|
||||
fontSize: context.fontSize || '',
|
||||
placeholderChips: placeholderChips,
|
||||
feedOptions: feedOptions,
|
||||
|
||||
@@ -21,24 +21,12 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
var disableAudio = context.disableAudio === undefined ? false : Boolean(context.disableAudio);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">RTMP</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">RTMP URL' +
|
||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="rtmp://example.com/live/stream" />' +
|
||||
'</label>' +
|
||||
'<label class="form-check m-0">' +
|
||||
'<input class="form-check-input" type="checkbox" name="region_disable_audio_' + region.id + '" value="1"' + (disableAudio ? ' checked' : '') + ' />' +
|
||||
'<span class="form-check-label">Disable audio</span>' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">Player controls stay hidden and the stream is not interactive.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">RTMP</span>',
|
||||
bodyHtml: '' +
|
||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="rtmp://example.com/live/stream" />'
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('rtmp', {
|
||||
|
||||
@@ -219,7 +219,7 @@
|
||||
'<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 class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code> or <code>{{start.format("MMM D, YYYY h:mm A")}}</code>.</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
|
||||
@@ -39,48 +39,45 @@
|
||||
var uploadVideoMaxLabel = String(uploadConfig.limitLabel || context.uploadVideoMaxLabel || '1 GB');
|
||||
var uploadAccept = Array.isArray(uploadConfig.accept) ? uploadConfig.accept.join(', ') : String(uploadConfig.accept || 'video/mp4, video/webm, video/ogg');
|
||||
var uploadHelpText = String(uploadConfig.helpText || 'MP4, WebM, or Ogg');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="video">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Video</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
|
||||
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop a video here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">Video</span>',
|
||||
cardAttrs: ' data-region-media-type="video"',
|
||||
bodyHtml: '' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
|
||||
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="' + escapeHtml(uploadAccept) + '" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop a video here or click to upload</strong>' +
|
||||
'<span>' + escapeHtml(uploadHelpText) + '</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + escapeHtml(uploadVideoMaxLabel) + ' per file</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
|
||||
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
|
||||
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'<input type="hidden" name="existing_region_video_duration_' + region.id + '" value="' + escapeHtml(currentDuration) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'<input type="hidden" name="existing_region_video_duration_' + region.id + '" value="' + escapeHtml(currentDuration) + '" />'
|
||||
});
|
||||
}
|
||||
|
||||
function buildEditorCardContext(context) {
|
||||
|
||||
@@ -20,19 +20,12 @@
|
||||
function renderEditorCard(context) {
|
||||
var region = context.region;
|
||||
var current = String(context.current || '');
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Webpage</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<label style="display:block;">Webpage URL' +
|
||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||
'</label>' +
|
||||
'<div class="muted slide-image-file">The webpage will be loaded in an iframe.</div>' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
return registry.renderEditorCardShell({
|
||||
region: region,
|
||||
headerActions: '<span class="chip">Webpage</span>',
|
||||
bodyHtml: '' +
|
||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />'
|
||||
});
|
||||
}
|
||||
|
||||
registry.register('webpage', {
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
// Shared QR SVG helpers for browser renderers.
|
||||
|
||||
(function () {
|
||||
function normalizeSvgMarkup(svg) {
|
||||
var markup = String(svg || '').trim();
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return markup.replace(/^<\?xml[^>]*>\s*/i, '');
|
||||
}
|
||||
|
||||
function stripBackground(svg) {
|
||||
var markup = String(svg || '').trim();
|
||||
if (!markup) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return markup
|
||||
.replace(/<path\b[^>]*fill="#ffffff"[^>]*d="M0 0h[^\"]*"[^>]*\s*\/>/i, '')
|
||||
.replace(/<path\b[^>]*fill="#ffffff"[^>]*d="M0 0h[^\"]*"[^>]*><\/path>/i, '')
|
||||
.replace(/<rect\b[^>]*fill="#ffffff"[^>]*x="0"[^>]*y="0"[^>]*width="\d+"[^>]*height="\d+"[^>]*\s*\/?>(?:<\/rect>)?/i, '');
|
||||
}
|
||||
|
||||
function normalizePreviewSvg(svg) {
|
||||
var markup = normalizeSvgMarkup(stripBackground(svg));
|
||||
if (!markup || markup.charAt(0) !== '<') {
|
||||
return markup;
|
||||
}
|
||||
|
||||
return markup.replace(/^<svg\b([^>]*)>/i, function (_match, attrText) {
|
||||
var attrs = String(attrText || '');
|
||||
if (!/\bwidth\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' width="95%"';
|
||||
}
|
||||
if (!/\bheight\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' height="95%"';
|
||||
}
|
||||
if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' preserveAspectRatio="xMidYMid meet"';
|
||||
}
|
||||
if (!/\bstyle\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' style="display:block;width:95%;height:95%;"';
|
||||
}
|
||||
return '<svg' + attrs + '>';
|
||||
});
|
||||
}
|
||||
|
||||
window.pulseQrSvgUtils = {
|
||||
normalizeSvgMarkup: normalizeSvgMarkup,
|
||||
stripBackground: stripBackground,
|
||||
normalizePreviewSvg: normalizePreviewSvg
|
||||
};
|
||||
}());
|
||||
@@ -68,6 +68,32 @@ export function createSlideFormEditorController(options) {
|
||||
return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null;
|
||||
}
|
||||
|
||||
function getTinymce() {
|
||||
return window.tinymce || null;
|
||||
}
|
||||
|
||||
function getThemeName() {
|
||||
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
|
||||
if (theme === 'auto') {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function getTinyMceThemeAssets() {
|
||||
var themeName = getThemeName();
|
||||
var skinName = themeName === 'dark' ? 'oxide-dark' : 'oxide';
|
||||
var skinBase = '/assets/vendor/tinymce/skins/ui/' + skinName;
|
||||
return {
|
||||
themeName: themeName,
|
||||
skinName: skinName,
|
||||
skinUrl: skinBase,
|
||||
contentCss: skinBase + '/content.css',
|
||||
bodyClass: themeName === 'dark' ? 'tinymce-theme-dark' : 'tinymce-theme-light'
|
||||
};
|
||||
}
|
||||
|
||||
function getEditorRegionType(card) {
|
||||
if (!card) {
|
||||
return '';
|
||||
@@ -99,31 +125,6 @@ export function createSlideFormEditorController(options) {
|
||||
fontSizeHidden.value = normalizeFontSizeValue(fontSizeHidden.value) || String(defaultFontSize);
|
||||
}
|
||||
|
||||
function getTinymce() {
|
||||
return window.tinymce || null;
|
||||
}
|
||||
|
||||
function getThemeName() {
|
||||
var theme = String(document.documentElement && document.documentElement.dataset && document.documentElement.dataset.bsTheme || 'light').trim().toLowerCase();
|
||||
if (theme === 'auto') {
|
||||
return window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
return theme === 'dark' ? 'dark' : 'light';
|
||||
}
|
||||
|
||||
function getTinyMceThemeAssets() {
|
||||
var themeName = getThemeName();
|
||||
var skinName = themeName === 'dark' ? 'oxide-dark' : 'oxide';
|
||||
var skinBase = '/assets/vendor/tinymce/skins/ui/' + skinName;
|
||||
return {
|
||||
themeName: themeName,
|
||||
skinName: skinName,
|
||||
skinUrl: skinBase,
|
||||
contentCss: skinBase + '/content.css',
|
||||
bodyClass: themeName === 'dark' ? 'tinymce-theme-dark' : 'tinymce-theme-light'
|
||||
};
|
||||
}
|
||||
|
||||
function getEditorBackgroundColorValue() {
|
||||
var value = String(getEditorBackgroundColor() || '').trim();
|
||||
return value || '#111111';
|
||||
@@ -161,28 +162,19 @@ export function createSlideFormEditorController(options) {
|
||||
function attachEditorEvents(regionId, editor) {
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var source = editor && editor.targetElm ? editor.targetElm : null;
|
||||
var hydrated = false;
|
||||
|
||||
function syncState() {
|
||||
function syncState(shouldLock) {
|
||||
var currentEditor = editorInstances.get(regionId);
|
||||
if (currentEditor !== editor) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof editor.save === 'function') {
|
||||
editor.save();
|
||||
syncEditorState(regionId, editor, shouldLock, hydrated);
|
||||
if (!hydrated) {
|
||||
requestPreviewRender();
|
||||
return;
|
||||
}
|
||||
var content = editor.getContent({ format: 'html' });
|
||||
content = isEmptyRichTextValue(content) ? '' : content;
|
||||
if (hidden) {
|
||||
hidden.value = content;
|
||||
}
|
||||
if (source) {
|
||||
source.value = content;
|
||||
}
|
||||
if (templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
|
||||
templateSelectorLock.markEdited();
|
||||
}
|
||||
requestPreviewRender();
|
||||
}
|
||||
|
||||
editor.on('init', function () {
|
||||
@@ -195,11 +187,14 @@ export function createSlideFormEditorController(options) {
|
||||
source.value = content;
|
||||
}
|
||||
syncEditorFontSizeHidden(regionId);
|
||||
syncState();
|
||||
hydrated = true;
|
||||
requestPreviewRender();
|
||||
});
|
||||
|
||||
['change', 'keyup', 'undo', 'redo', 'SetContent', 'Paste', 'input', 'NodeChange'].forEach(function (eventName) {
|
||||
editor.on(eventName, syncState);
|
||||
['change', 'keyup', 'undo', 'redo', 'Paste', 'input'].forEach(function (eventName) {
|
||||
editor.on(eventName, function () {
|
||||
syncState(true);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -226,15 +221,74 @@ export function createSlideFormEditorController(options) {
|
||||
source.id = 'slide-editor-region-' + regionId;
|
||||
}
|
||||
|
||||
function registerInlineFormat(editor, formatName, styles) {
|
||||
editor.formatter.register(formatName, {
|
||||
inline: 'span',
|
||||
styles: styles
|
||||
});
|
||||
}
|
||||
|
||||
function syncEditorState(regionId, editor, shouldLock, hydrated) {
|
||||
if (editor && typeof editor.save === 'function') {
|
||||
editor.save();
|
||||
}
|
||||
var content = editor.getContent({ format: 'html' });
|
||||
content = isEmptyRichTextValue(content) ? '' : content;
|
||||
var hidden = getEditorHiddenInput(regionId);
|
||||
var sourceElm = editor && editor.targetElm ? editor.targetElm : null;
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = content;
|
||||
}
|
||||
if (sourceElm) {
|
||||
sourceElm.value = content;
|
||||
}
|
||||
if (typeof editor.nodeChanged === 'function') {
|
||||
editor.nodeChanged();
|
||||
}
|
||||
if (typeof editor.setDirty === 'function') {
|
||||
editor.setDirty(true);
|
||||
}
|
||||
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.arm === 'function') {
|
||||
templateSelectorLock.arm();
|
||||
}
|
||||
if (shouldLock && templateSelectorLock && typeof templateSelectorLock.markEdited === 'function') {
|
||||
templateSelectorLock.markEdited();
|
||||
}
|
||||
if (hydrated) {
|
||||
requestPreviewRender();
|
||||
}
|
||||
}
|
||||
|
||||
function applyInlineFormat(regionId, editor, formatName) {
|
||||
if (editor && editor.undoManager && typeof editor.undoManager.transact === 'function') {
|
||||
editor.undoManager.transact(function () {
|
||||
editor.formatter.apply(formatName);
|
||||
});
|
||||
} else {
|
||||
editor.formatter.apply(formatName);
|
||||
}
|
||||
syncEditorState(regionId, editor, false, true);
|
||||
}
|
||||
|
||||
function applyEditorCommand(regionId, editor, commandName) {
|
||||
if (editor && editor.undoManager && typeof editor.undoManager.transact === 'function') {
|
||||
editor.undoManager.transact(function () {
|
||||
editor.execCommand(commandName);
|
||||
});
|
||||
} else {
|
||||
editor.execCommand(commandName);
|
||||
}
|
||||
syncEditorState(regionId, editor, false, true);
|
||||
}
|
||||
|
||||
return tinymce.init({
|
||||
target: source,
|
||||
menubar: false,
|
||||
branding: false,
|
||||
promotion: false,
|
||||
statusbar: true,
|
||||
resize: true,
|
||||
plugins: 'lists code advlist fullscreen table',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underline strikethrough subscript superscript removeformat | align lineheight indent outdent bullist numlist table | fullscreen',
|
||||
toolbar: 'undo redo | fontfamily fontsizeinput | forecolor backcolor bold italic underlineformats removeformat | align lineheight indent outdent bullist numlist table chip | fullscreen',
|
||||
toolbar_mode: 'sliding',
|
||||
license_key: 'gpl',
|
||||
skin: themeAssets.skinName,
|
||||
@@ -251,6 +305,64 @@ export function createSlideFormEditorController(options) {
|
||||
placeholder: String(source.getAttribute('placeholder') || '').trim(),
|
||||
setup: function (editor) {
|
||||
editorInstances.set(regionId, editor);
|
||||
editor.ui.registry.addButton('chip', {
|
||||
icon: 'addtag',
|
||||
tooltip: 'Chip style',
|
||||
onAction: function () {
|
||||
applyInlineFormat(regionId, editor, 'chip');
|
||||
}
|
||||
});
|
||||
editor.ui.registry.addSplitButton('underlineformats', {
|
||||
icon: 'underline',
|
||||
tooltip: 'Text formats',
|
||||
onAction: function () {
|
||||
applyEditorCommand(regionId, editor, 'Underline');
|
||||
},
|
||||
onItemAction: function (_api, value) {
|
||||
if (value === 'strikethrough') {
|
||||
applyEditorCommand(regionId, editor, 'Strikethrough');
|
||||
return;
|
||||
}
|
||||
if (value === 'subscript') {
|
||||
applyEditorCommand(regionId, editor, 'Subscript');
|
||||
return;
|
||||
}
|
||||
if (value === 'superscript') {
|
||||
applyEditorCommand(regionId, editor, 'Superscript');
|
||||
}
|
||||
},
|
||||
fetch: function (callback) {
|
||||
callback([
|
||||
{
|
||||
type: 'choiceitem',
|
||||
value: 'strikethrough',
|
||||
icon: 'strikethrough',
|
||||
text: 'Strikethrough',
|
||||
},
|
||||
{
|
||||
type: 'choiceitem',
|
||||
value: 'subscript',
|
||||
icon: 'subscript',
|
||||
text: 'Subscript',
|
||||
},
|
||||
{
|
||||
type: 'choiceitem',
|
||||
value: 'superscript',
|
||||
icon: 'superscript',
|
||||
text: 'Superscript',
|
||||
}
|
||||
]);
|
||||
}
|
||||
});
|
||||
editor.on('init', function () {
|
||||
registerInlineFormat(editor, 'chip', {
|
||||
display: 'inline-block',
|
||||
padding: '0.2em 0.6em',
|
||||
borderRadius: '999px',
|
||||
fontWeight: '700',
|
||||
letterSpacing: '0.02em'
|
||||
});
|
||||
});
|
||||
attachEditorEvents(regionId, editor);
|
||||
}
|
||||
}).then(function (editors) {
|
||||
|
||||
@@ -10,6 +10,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
var apiSources = Array.isArray(settings.apiSources) ? settings.apiSources : [];
|
||||
var timetableGroups = Array.isArray(settings.timetableGroups) ? settings.timetableGroups : [];
|
||||
var getRegionTypeModule = typeof settings.getRegionTypeModule === 'function' ? settings.getRegionTypeModule : function () { return null; };
|
||||
var requestPreviewRender = typeof settings.requestPreviewRender === 'function' ? settings.requestPreviewRender : function () {};
|
||||
var placeholderUtils = window.placeholderUtils || {};
|
||||
var placeholderChips = window.placeholderChips || {};
|
||||
var escapeHtml = typeof settings.escapeHtml === 'function'
|
||||
@@ -76,6 +77,11 @@ export function createSlideFormRegionHelpers(options) {
|
||||
}) || null;
|
||||
}
|
||||
|
||||
function getRssFeedItemCount(feedId) {
|
||||
var feed = getRssFeedById(feedId);
|
||||
return feed && Array.isArray(feed.items) ? feed.items.length : 0;
|
||||
}
|
||||
|
||||
function getRssFieldList(feedId) {
|
||||
var module = getRegionTypeModule('rss');
|
||||
if (module && typeof module.getFieldList === 'function') {
|
||||
@@ -128,16 +134,18 @@ export function createSlideFormRegionHelpers(options) {
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
}).join('');
|
||||
var transformHint = '<div class="text-body-secondary small mt-1">Placeholder values support transforms, for example <code>{{title.upper()}}</code>, <code>{{title.title()}}</code>, <code>{{title.lower()}}</code>, <code>{{publishedAt.format("MMM D, YYYY")}}</code>.</div>';
|
||||
|
||||
if (!overflowFields.length) {
|
||||
return visibleMarkup;
|
||||
return visibleMarkup + transformHint;
|
||||
}
|
||||
|
||||
return '' +
|
||||
visibleMarkup +
|
||||
'<details class="w-100 mt-2" data-placeholder-chips-more>' +
|
||||
transformHint +
|
||||
'<details class="w-100 mt-1" data-placeholder-chips-more>' +
|
||||
'<summary class="small text-body-secondary">Show ' + escapeHtml(overflowFields.length) + ' more fields</summary>' +
|
||||
'<div class="d-flex flex-wrap gap-2 mt-2">' + overflowFields.map(function (field) {
|
||||
'<div class="d-flex flex-wrap gap-2 mt-1">' + overflowFields.map(function (field) {
|
||||
return typeof placeholderChips.renderChip === 'function'
|
||||
? placeholderChips.renderChip(field)
|
||||
: '<span class="chip">{{' + escapeHtml(field) + '}}</span>';
|
||||
@@ -212,6 +220,43 @@ export function createSlideFormRegionHelpers(options) {
|
||||
container.innerHTML = buildLimitedPlaceholderChipMarkup(getRssFieldList(feedId), 'No RSS fields available.');
|
||||
}
|
||||
|
||||
function updateRssItemNumberLimit(regionId, feedId) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var input = card.querySelector('input[name="region_rss_item_number_' + regionId + '"]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var itemCount = Math.max(1, Number(getRssFeedItemCount(feedId) || 1));
|
||||
input.setAttribute('max', String(itemCount));
|
||||
if (Number(input.value || 0) > itemCount) {
|
||||
input.value = String(itemCount);
|
||||
}
|
||||
}
|
||||
|
||||
function clampRssItemNumber(regionId, feedId, itemNumber) {
|
||||
var card = templateFields ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
if (!card) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var input = card.querySelector('input[name="region_rss_item_number_' + regionId + '"]');
|
||||
if (!input) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var itemCount = Math.max(1, Number(getRssFeedItemCount(feedId) || 1));
|
||||
var parsedValue = Math.max(1, Number(itemNumber || 1));
|
||||
var clampedValue = Math.min(itemCount, Number.isFinite(parsedValue) && parsedValue > 0 ? parsedValue : 1);
|
||||
input.setAttribute('max', String(itemCount));
|
||||
input.value = String(clampedValue);
|
||||
return String(clampedValue);
|
||||
}
|
||||
|
||||
function getCurrentRssConfig(region) {
|
||||
var module = getRegionTypeModule('rss');
|
||||
if (module && typeof module.getCurrentConfig === 'function') {
|
||||
@@ -568,6 +613,50 @@ export function createSlideFormRegionHelpers(options) {
|
||||
return makeRegionCardCollapsible(module.renderEditorCard(buildRegionEditorCardContext(region)), region);
|
||||
}
|
||||
|
||||
function bindRegionCards() {
|
||||
if (!templateFields) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(templateFields.querySelectorAll('[data-region-id]'), function (card) {
|
||||
var module = null;
|
||||
|
||||
if (!card || !card.querySelector || !card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
module = getRegionTypeModule('qr-code');
|
||||
|
||||
if (module && typeof module.bindEditorCard === 'function') {
|
||||
module.bindEditorCard({
|
||||
card: card,
|
||||
region: { id: card.getAttribute('data-region-id') },
|
||||
existingContent: existingContent,
|
||||
requestPreviewRender: requestPreviewRender
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function syncRegionCards() {
|
||||
if (!templateFields) {
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
var syncs = [];
|
||||
Array.prototype.forEach.call(templateFields.querySelectorAll('[data-region-id]'), function (card) {
|
||||
if (!card || !card.querySelector || !card.querySelector('input[type="url"][name^="region_qr_code_"]')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof card.__pulseQrSync === 'function') {
|
||||
syncs.push(Promise.resolve(card.__pulseQrSync()));
|
||||
}
|
||||
});
|
||||
|
||||
return Promise.all(syncs);
|
||||
}
|
||||
|
||||
function makeRegionCardCollapsible(html, region) {
|
||||
if (typeof html !== 'string' || !html) {
|
||||
return html;
|
||||
@@ -666,7 +755,6 @@ export function createSlideFormRegionHelpers(options) {
|
||||
var videoFileInput = card.querySelector('input[type="file"][name="region_video_' + region.id + '"]');
|
||||
var webpageInput = card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
|
||||
var rtmpInput = card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
|
||||
var disableAudioInput = card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
|
||||
var rssFeedInput = card.querySelector('select[name="region_rss_feed_id_' + region.id + '"]');
|
||||
var rssItemInput = card.querySelector('input[name="region_rss_item_number_' + region.id + '"]');
|
||||
var apiSourceInput = card.querySelector('select[name="region_api_source_id_' + region.id + '"]');
|
||||
@@ -681,7 +769,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
content.value = webpageInput ? webpageInput.value : (content.value || '');
|
||||
} else if (region.region_type === 'rtmp') {
|
||||
content.value = rtmpInput ? rtmpInput.value : (content.value || '');
|
||||
content.disable_audio = disableAudioInput ? disableAudioInput.checked : (content.disable_audio === undefined ? true : Boolean(content.disable_audio));
|
||||
content.disable_audio = false;
|
||||
} else if (region.region_type === 'html') {
|
||||
content.value = htmlInput ? htmlInput.value : (content.value || '');
|
||||
} else if (region.region_type === 'time-date') {
|
||||
@@ -691,7 +779,7 @@ export function createSlideFormRegionHelpers(options) {
|
||||
} else if (region.region_type === 'rss') {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
content.feed_id = rssFeedInput ? rssFeedInput.value : content.feed_id;
|
||||
content.item_number = rssItemInput ? rssItemInput.value : content.item_number;
|
||||
content.item_number = rssItemInput ? clampRssItemNumber(region.id, content.feed_id, rssItemInput.value) : content.item_number;
|
||||
} else if (region.region_type === 'api') {
|
||||
content.value = hiddenInput ? hiddenInput.value : (content.value || '');
|
||||
content.source_id = apiSourceInput ? apiSourceInput.value : content.source_id;
|
||||
@@ -754,11 +842,14 @@ export function createSlideFormRegionHelpers(options) {
|
||||
updateApiPlaceholderChips: updateApiPlaceholderChips,
|
||||
updateApiSampleDataPanel: updateApiSampleDataPanel,
|
||||
updateApiItemNumberLimit: updateApiItemNumberLimit,
|
||||
updateRssItemNumberLimit: updateRssItemNumberLimit,
|
||||
getCurrentApiConfig: getCurrentApiConfig,
|
||||
reduceAspectRatio: reduceAspectRatio,
|
||||
renderPreviewTextRegion: renderPreviewTextRegion,
|
||||
startPreviewVideoPlayback: startPreviewVideoPlayback,
|
||||
getRegionEditorCardHtml: getRegionEditorCardHtml,
|
||||
bindRegionCards: bindRegionCards,
|
||||
syncRegionCards: syncRegionCards,
|
||||
buildRegionEditorCardContext: buildRegionEditorCardContext,
|
||||
getPreviewRegionContent: getPreviewRegionContent,
|
||||
buildPreviewRenderContext: buildPreviewRenderContext,
|
||||
|
||||
@@ -114,7 +114,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
requestPreviewRender: requestPreviewRender
|
||||
});
|
||||
var regionUtils = window.pulseRegionUtils || {};
|
||||
var slideRegionHelpers = createSlideFormRegionHelpers({
|
||||
var slideRegionHelperOptions = {
|
||||
templateFields: templateFields,
|
||||
existingContent: existingContent,
|
||||
rssFeeds: rssFeeds,
|
||||
@@ -124,8 +124,10 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
uploadMaxLabel: uploadMaxLabel,
|
||||
uploadVideoMaxLabel: uploadVideoMaxLabel,
|
||||
escapeHtml: escapeHtml,
|
||||
getRegionTypeModule: getRegionTypeModule
|
||||
});
|
||||
getRegionTypeModule: getRegionTypeModule,
|
||||
requestPreviewRender: requestPreviewRender
|
||||
};
|
||||
var slideRegionHelpers = createSlideFormRegionHelpers(slideRegionHelperOptions);
|
||||
var slidePreviewHelpers = createSlideFormPreviewHelpers({
|
||||
getRegionTypeModule: getRegionTypeModule,
|
||||
escapeHtml: escapeHtml
|
||||
@@ -139,6 +141,8 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var renderPreviewTextRegion = slidePreviewHelpers.renderPreviewTextRegion;
|
||||
var buildRegionEditorCardContext = slideRegionHelpers.buildRegionEditorCardContext;
|
||||
var getRegionEditorCardHtml = slideRegionHelpers.getRegionEditorCardHtml;
|
||||
var bindRegionCards = slideRegionHelpers.bindRegionCards;
|
||||
var syncRegionCards = slideRegionHelpers.syncRegionCards;
|
||||
var getPreviewRegionContent = slideRegionHelpers.getPreviewRegionContent;
|
||||
var buildPreviewRenderContext = slideRegionHelpers.buildPreviewRenderContext;
|
||||
var startPreviewVideoPlayback = slidePreviewHelpers.startPreviewVideoPlayback;
|
||||
@@ -147,6 +151,28 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return templates.find(function (item) { return Number(item.id) === Number(templateSelect.value); }) || null;
|
||||
}
|
||||
|
||||
function hasMeaningfulValue(value) {
|
||||
if (value === null || value === undefined) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(hasMeaningfulValue);
|
||||
}
|
||||
|
||||
if (typeof value === 'object') {
|
||||
return Object.keys(value).some(function (key) {
|
||||
return hasMeaningfulValue(value[key]);
|
||||
});
|
||||
}
|
||||
|
||||
return String(value).trim() !== '';
|
||||
}
|
||||
|
||||
function hasExistingSlideContent() {
|
||||
return hasMeaningfulValue(existingContent);
|
||||
}
|
||||
|
||||
function clearImagePreviewUrls() {
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
|
||||
if (input.dataset.previewUrl) {
|
||||
@@ -170,6 +196,10 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return String(card.dataset.regionMediaType || '').trim();
|
||||
}
|
||||
|
||||
if (card.querySelector && card.querySelector('input[type="file"][name^="region_qr_image_"], input[type="url"][name^="region_qr_code_"]')) {
|
||||
return 'qr-code';
|
||||
}
|
||||
|
||||
var regionTypeInput = card.querySelector ? card.querySelector('input[type="hidden"][name="region_type[]"]') : null;
|
||||
return String(regionTypeInput && regionTypeInput.value || '').trim();
|
||||
}
|
||||
@@ -192,6 +222,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return syncVideoRegionDuration(card, source);
|
||||
},
|
||||
markEdited: function () {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
},
|
||||
@@ -201,7 +232,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
}) : null;
|
||||
|
||||
function buildRegionMediaHookContext(input, file) {
|
||||
var regionId = input && input.name ? input.name.replace(/^region_(?:image|video)_/, '') : '';
|
||||
var regionId = input && input.name ? input.name.replace(/^region_(?:image|video|qr_image)_/, '') : '';
|
||||
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
|
||||
return {
|
||||
@@ -220,7 +251,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
}
|
||||
|
||||
function handleRegionMediaFileSelection(input, file) {
|
||||
var card = input && input.name ? templateFields.querySelector('[data-region-id="' + input.name.replace(/^region_(?:image|video)_/, '') + '"]') : null;
|
||||
var card = input && input.name ? templateFields.querySelector('[data-region-id="' + input.name.replace(/^region_(?:image|video|qr_image)_/, '') + '"]') : null;
|
||||
var module = getRegionEditorModule(card);
|
||||
var hookContext = buildRegionMediaHookContext(input, file);
|
||||
|
||||
@@ -241,14 +272,19 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaType = card && card.dataset && String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
var mediaType = card && card.dataset && String(card.dataset.regionMediaType || '').trim() === 'video'
|
||||
? 'video'
|
||||
: card && card.querySelector && card.querySelector('input[type="file"][name^="region_qr_image_"]')
|
||||
? 'qr-image'
|
||||
: 'image';
|
||||
var isVideo = mediaType === 'video';
|
||||
var isQrImage = mediaType === 'qr-image';
|
||||
|
||||
previewBox.innerHTML = value
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : 'image') + '">' +
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : isQrImage ? 'QR image' : 'image') + '">' +
|
||||
(isVideo
|
||||
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>'
|
||||
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />') +
|
||||
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="' + (isQrImage ? 'Current QR image preview' : 'Current image preview') + '" />') +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>';
|
||||
@@ -389,15 +425,15 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
}
|
||||
|
||||
function getRegionImageInputs() {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]'));
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]'));
|
||||
}
|
||||
|
||||
function getRegionMediaInputs() {
|
||||
return getRegionImageInputs();
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"], input[type="file"][name^="region_qr_image_"]'));
|
||||
}
|
||||
|
||||
function hasPendingRegionImageUpload() {
|
||||
return getRegionImageInputs().some(function (input) {
|
||||
return getRegionMediaInputs().some(function (input) {
|
||||
return Boolean(input && input.files && input.files.length);
|
||||
});
|
||||
}
|
||||
@@ -448,6 +484,14 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
if (previewPopupWindow.location && previewPopupWindow.location.pathname !== '/slides/popup-preview') {
|
||||
return;
|
||||
}
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
|
||||
previewPopupWindow.location.hash = buildPreviewPopupHash();
|
||||
}
|
||||
|
||||
@@ -471,13 +515,18 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
'top=' + top
|
||||
].join(',');
|
||||
|
||||
previewPopupWindow = window.open(buildPreviewPopupUrl(), 'slide-preview-popup', previewPopupFeatures);
|
||||
previewPopupWindow = window.open('about:blank', 'slide-preview-popup', previewPopupFeatures);
|
||||
if (!previewPopupWindow) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
previewPopupWindow.location.replace(buildPreviewPopupUrl());
|
||||
} catch (_error) {
|
||||
previewPopupWindow.location.href = buildPreviewPopupUrl();
|
||||
}
|
||||
|
||||
previewPopupWindow.focus();
|
||||
syncPopupPreview();
|
||||
}
|
||||
|
||||
function syncSlideEditorSidebar() {
|
||||
@@ -632,6 +681,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
var updateApiSampleDataPanel = slideRegionHelpers.updateApiSampleDataPanel;
|
||||
var updateApiItemNumberLimit = slideRegionHelpers.updateApiItemNumberLimit;
|
||||
var getCurrentApiConfig = slideRegionHelpers.getCurrentApiConfig;
|
||||
var updateRssItemNumberLimit = slideRegionHelpers.updateRssItemNumberLimit;
|
||||
|
||||
function renderTemplate() {
|
||||
templateSelectorLock.reset();
|
||||
@@ -663,6 +713,9 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
});
|
||||
|
||||
slideFormEditorController.renderEditors();
|
||||
window.requestAnimationFrame(function () {
|
||||
bindRegionCards();
|
||||
});
|
||||
templateFields.querySelectorAll('input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||
var regionId = input.name.replace(/^region_api_item_number_/, '');
|
||||
var sourceInput = templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]');
|
||||
@@ -671,19 +724,42 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
updateApiItemNumberLimit(regionId, sourceInput.value, pathInput ? pathInput.value : '');
|
||||
}
|
||||
});
|
||||
templateFields.querySelectorAll('input[name^="region_rss_item_number_"]').forEach(function (input) {
|
||||
var regionId = input.name.replace(/^region_rss_item_number_/, '');
|
||||
var feedInput = templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]');
|
||||
if (feedInput) {
|
||||
updateRssItemNumberLimit(regionId, feedInput.value);
|
||||
}
|
||||
});
|
||||
|
||||
if (typeof regionUtils.bindRegionMediaRemoveControls === 'function') {
|
||||
regionUtils.bindRegionMediaRemoveControls(templateFields, {
|
||||
getCardByRegionId: function (regionId) {
|
||||
return templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
},
|
||||
getMediaType: function (card) {
|
||||
getMediaType: function (card, button) {
|
||||
if (button && button.hasAttribute && button.hasAttribute('data-remove-region-qr-image')) {
|
||||
return 'qr-image';
|
||||
}
|
||||
|
||||
if (button && button.hasAttribute && button.hasAttribute('data-remove-region-video')) {
|
||||
return 'video';
|
||||
}
|
||||
|
||||
return card && card.dataset && String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
},
|
||||
getExistingMediaPrefix: function (mediaType) {
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'existing_region_qr_image_';
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
},
|
||||
getMediaPrefix: function (mediaType) {
|
||||
if (String(mediaType || '').trim() === 'qr-image') {
|
||||
return 'region_qr_image_';
|
||||
}
|
||||
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
},
|
||||
getVideoDurationHiddenInput: function (regionId) {
|
||||
@@ -711,6 +787,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
updateRssItemNumberLimit(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
@@ -728,6 +805,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
if (input.name.indexOf('region_rss_') === 0) {
|
||||
var rssFeed = input.name.indexOf('region_rss_feed_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_rss_feed_id_' + regionId + '"]') || {}).value;
|
||||
updateRssPlaceholderChips(regionId, rssFeed);
|
||||
updateRssItemNumberLimit(regionId, rssFeed);
|
||||
}
|
||||
if (input.name.indexOf('region_api_') === 0) {
|
||||
var apiSource = input.name.indexOf('region_api_source_id_') === 0 ? input.value : (templateFields.querySelector('select[name="region_api_source_id_' + regionId + '"]') || {}).value;
|
||||
@@ -742,16 +820,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
});
|
||||
});
|
||||
|
||||
if (existingTemplateId) {
|
||||
if (existingTemplateId && hasExistingSlideContent()) {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
return;
|
||||
}
|
||||
|
||||
window.requestAnimationFrame(function () {
|
||||
templateSelectorLock.arm();
|
||||
});
|
||||
requestPreviewRender();
|
||||
}
|
||||
|
||||
@@ -766,6 +840,7 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
throw new Error('Wait for the image upload to finish before saving.');
|
||||
}
|
||||
await slideFormEditorController.syncEditors();
|
||||
await syncRegionCards();
|
||||
},
|
||||
fallbackSuccessMessage: 'Saved slide.',
|
||||
afterSuccess: function () {
|
||||
@@ -792,10 +867,12 @@ import { createSlideFormPreviewHelpers } from '/assets/js/slides/slide-form-prev
|
||||
|
||||
templateSelect.addEventListener('change', renderTemplate);
|
||||
templateFields.addEventListener('change', function () {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
templateFields.addEventListener('input', function () {
|
||||
templateSelectorLock.arm();
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
|
||||
@@ -78,6 +78,8 @@
|
||||
return Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
var MAX_ANIMATION_REPEAT_COUNT = 999;
|
||||
|
||||
function getRegionName(card) {
|
||||
return String(card.querySelector('[name="region_name[]"]').value || '').trim();
|
||||
}
|
||||
@@ -204,7 +206,9 @@
|
||||
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
|
||||
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
|
||||
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
|
||||
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0
|
||||
? clamp(Math.round(Number(value.iterations)), 1, MAX_ANIMATION_REPEAT_COUNT)
|
||||
: null;
|
||||
normalized.easing = String(value.easing || '').trim();
|
||||
normalized.fill_mode = String(value.fill_mode || '').trim();
|
||||
return normalized;
|
||||
|
||||
@@ -50,6 +50,8 @@
|
||||
var selectedIndex = -1;
|
||||
var activeAnimationCard = null;
|
||||
var overlayRenderFrame = 0;
|
||||
var stageResizeObserver = null;
|
||||
var regionNameValidationToastShown = false;
|
||||
var previewState = {
|
||||
active: false,
|
||||
mode: '',
|
||||
@@ -192,6 +194,40 @@
|
||||
card.querySelector('[name="region_label[]"]').value = next;
|
||||
}
|
||||
|
||||
function normalizeRegionNamePrefix(regionType) {
|
||||
var prefix = String(regionType || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '');
|
||||
return prefix || 'region';
|
||||
}
|
||||
|
||||
function buildDefaultRegionName(regionType, index) {
|
||||
return normalizeRegionNamePrefix(regionType) + '_' + index;
|
||||
}
|
||||
|
||||
function getNextDefaultRegionName(regionType) {
|
||||
var normalizedType = normalizeRegionNamePrefix(regionType);
|
||||
var nextIndex = getCards().reduce(function (count, card) {
|
||||
var currentType = normalizeRegionNamePrefix(card.querySelector('[name="region_type[]"]').value || '');
|
||||
if (currentType !== normalizedType) {
|
||||
return count;
|
||||
}
|
||||
|
||||
var currentName = getRegionName(card);
|
||||
var expectedPrefix = normalizedType + '_';
|
||||
if (currentName.indexOf(expectedPrefix) !== 0) {
|
||||
return count;
|
||||
}
|
||||
|
||||
var suffix = Number(currentName.slice(expectedPrefix.length));
|
||||
if (!Number.isFinite(suffix) || suffix < 1) {
|
||||
return count;
|
||||
}
|
||||
|
||||
return Math.max(count, suffix);
|
||||
}, 0) + 1;
|
||||
|
||||
return buildDefaultRegionName(normalizedType, nextIndex);
|
||||
}
|
||||
|
||||
function validateRegionNames() {
|
||||
var cards = getCards();
|
||||
var names = {};
|
||||
@@ -230,6 +266,17 @@
|
||||
return !hasDuplicate;
|
||||
}
|
||||
|
||||
function notifyRegionNameValidationError(message) {
|
||||
if (regionNameValidationToastShown) {
|
||||
return;
|
||||
}
|
||||
|
||||
regionNameValidationToastShown = true;
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(message || 'Region names must be unique on this template.', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
function readCard(card) {
|
||||
return utils.readCard ? utils.readCard(card) : {
|
||||
region_key: getRegionName(card),
|
||||
@@ -304,6 +351,19 @@
|
||||
});
|
||||
}
|
||||
|
||||
function updatePreviewButtonIcon(button, iconClass) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var icon = button.querySelector('[data-preview-icon]');
|
||||
if (!icon) {
|
||||
return;
|
||||
}
|
||||
|
||||
icon.className = 'bi ' + iconClass + ' me-1';
|
||||
}
|
||||
|
||||
function updatePreviewControlState() {
|
||||
var activeMode = previewState.active ? previewState.mode : '';
|
||||
|
||||
@@ -315,7 +375,9 @@
|
||||
var available = hasPreviewAnimationForMode(entry.mode);
|
||||
var disabled = !available || (previewState.active && activeMode !== entry.mode);
|
||||
entry.button.disabled = disabled;
|
||||
entry.button.classList.toggle('active', previewState.active && activeMode === entry.mode);
|
||||
entry.button.classList.toggle('btn-outline-secondary', disabled);
|
||||
entry.button.classList.toggle('btn-info', !disabled);
|
||||
updatePreviewButtonIcon(entry.button, previewState.active && activeMode === entry.mode ? 'bi-stop-fill' : 'bi-play-fill');
|
||||
entry.button.setAttribute('aria-pressed', previewState.active && activeMode === entry.mode ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
@@ -380,6 +442,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
var MAX_ANIMATION_REPEAT_COUNT = 999;
|
||||
|
||||
function getPreviewAnimationStepTimingMs(step, mode, animateCssMode) {
|
||||
var normalizedMode = String(mode || '').trim().toLowerCase();
|
||||
var durationMs = Number(step && step.duration_ms);
|
||||
@@ -416,7 +480,9 @@
|
||||
normalized.preset = normalizePreviewAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
||||
normalized.duration_ms = Number.isFinite(Number(value.duration_ms)) && Number(value.duration_ms) > 0 ? Math.round(Number(value.duration_ms)) : null;
|
||||
normalized.delay_ms = Number.isFinite(Number(value.delay_ms)) && Number(value.delay_ms) >= 0 ? Math.round(Number(value.delay_ms)) : null;
|
||||
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0 ? Math.round(Number(value.iterations)) : null;
|
||||
normalized.iterations = Number.isFinite(Number(value.iterations)) && Number(value.iterations) > 0
|
||||
? Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Math.round(Number(value.iterations))))
|
||||
: null;
|
||||
normalized.fill_mode = String(value.fill_mode || '').trim();
|
||||
return normalized;
|
||||
}
|
||||
@@ -599,11 +665,11 @@
|
||||
var delayInput = getAnimationModalField(stepName, 'delay_ms');
|
||||
|
||||
if (presetInput) { presetInput.value = step.preset; }
|
||||
if (durationInput) { durationInput.value = step.duration_ms === null || step.duration_ms === undefined || Number(step.duration_ms) <= 0 ? '1000' : String(step.duration_ms); }
|
||||
if (durationInput) { durationInput.value = String(getAnimationDurationPercent(step.duration_ms)); }
|
||||
if (delayInput) { delayInput.value = step.delay_ms === null || step.delay_ms === undefined ? '0' : String(step.delay_ms); }
|
||||
if (stepName === 'loop') {
|
||||
var iterationsInput = getAnimationModalField(stepName, 'iterations');
|
||||
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(step.iterations); }
|
||||
if (iterationsInput) { iterationsInput.value = step.iterations === null || step.iterations === undefined ? '1' : String(Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, Number(step.iterations) || 1))); }
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -617,6 +683,44 @@
|
||||
return Number.isFinite(next) ? Math.round(next) : null;
|
||||
}
|
||||
|
||||
function clampAnimationModalField(field) {
|
||||
if (!field || !field.matches) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isLoopIterationsField = field.matches('[data-animation-modal-step="loop"][data-animation-modal-field="iterations"]');
|
||||
var isDurationField = field.matches('[data-animation-modal-field="duration_ms"]');
|
||||
if (!isLoopIterationsField && !isDurationField) {
|
||||
return;
|
||||
}
|
||||
|
||||
var fieldValue = readNumberField(field.value);
|
||||
if (fieldValue === null) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedValue = isLoopIterationsField
|
||||
? Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, fieldValue))
|
||||
: Math.min(200, Math.max(1, fieldValue));
|
||||
if (String(normalizedValue) !== String(field.value)) {
|
||||
field.value = String(normalizedValue);
|
||||
}
|
||||
}
|
||||
|
||||
function getAnimationDurationPercent(durationMs) {
|
||||
var value = Number(durationMs);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 100;
|
||||
}
|
||||
|
||||
return Math.min(200, Math.max(1, Math.round(value / 10)));
|
||||
}
|
||||
|
||||
function getAnimationDurationMs(durationPercent) {
|
||||
var percent = Math.min(200, Math.max(1, readNumberField(durationPercent) || 100));
|
||||
return percent * 10;
|
||||
}
|
||||
|
||||
function readAnimationModalConfig() {
|
||||
if (!animationAdvancedModal) {
|
||||
return null;
|
||||
@@ -631,11 +735,11 @@
|
||||
|
||||
var nextStep = Object.assign({}, config[stepName] || {});
|
||||
nextStep.preset = presetInput ? presetInput.value : nextStep.preset;
|
||||
nextStep.duration_ms = readNumberField(durationInput ? durationInput.value : '') || 1000;
|
||||
nextStep.duration_ms = getAnimationDurationMs(durationInput ? durationInput.value : '');
|
||||
nextStep.delay_ms = readNumberField(delayInput ? delayInput.value : '') || 0;
|
||||
if (stepName === 'loop') {
|
||||
var iterationsInput = getAnimationModalField(stepName, 'iterations');
|
||||
nextStep.iterations = readNumberField(iterationsInput ? iterationsInput.value : '') || 1;
|
||||
nextStep.iterations = Math.min(MAX_ANIMATION_REPEAT_COUNT, Math.max(1, readNumberField(iterationsInput ? iterationsInput.value : '') || 1));
|
||||
}
|
||||
|
||||
config[stepName] = normalizeAnimationStep(nextStep, 'none');
|
||||
@@ -892,6 +996,104 @@
|
||||
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
|
||||
updateAspectRatio();
|
||||
updateCanvasSizeSummary();
|
||||
getCards().forEach(function (card) {
|
||||
normalizeRegionCard(card, 'width');
|
||||
});
|
||||
}
|
||||
|
||||
function fitRegionToCanvas(region, aspect) {
|
||||
var size = getCanvasSize();
|
||||
var minSize = 12;
|
||||
var next = {
|
||||
x: Math.round(Number(region && region.x !== undefined ? region.x : 0)),
|
||||
y: Math.round(Number(region && region.y !== undefined ? region.y : 0)),
|
||||
width: Math.max(minSize, Math.round(Number(region && region.width !== undefined ? region.width : minSize))),
|
||||
height: Math.max(minSize, Math.round(Number(region && region.height !== undefined ? region.height : minSize)))
|
||||
};
|
||||
|
||||
if (aspect && Number.isFinite(aspect.ratio) && aspect.ratio > 0) {
|
||||
if (next.width > size.width || next.height > size.height) {
|
||||
var canvasRatio = size.width / Math.max(1, size.height);
|
||||
if (canvasRatio >= aspect.ratio) {
|
||||
next.height = Math.max(minSize, Math.min(size.height, next.height, Math.round(size.height)));
|
||||
next.width = Math.max(minSize, Math.round(next.height * aspect.ratio));
|
||||
if (next.width > size.width) {
|
||||
next.width = size.width;
|
||||
next.height = Math.max(minSize, Math.round(next.width / aspect.ratio));
|
||||
}
|
||||
} else {
|
||||
next.width = Math.max(minSize, Math.min(size.width, next.width, Math.round(size.width)));
|
||||
next.height = Math.max(minSize, Math.round(next.width / aspect.ratio));
|
||||
if (next.height > size.height) {
|
||||
next.height = size.height;
|
||||
next.width = Math.max(minSize, Math.round(next.height * aspect.ratio));
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (next.width > size.width) {
|
||||
next.width = size.width;
|
||||
next.x = 0;
|
||||
}
|
||||
if (next.height > size.height) {
|
||||
next.height = size.height;
|
||||
next.y = 0;
|
||||
}
|
||||
}
|
||||
|
||||
if (next.x + next.width > size.width) {
|
||||
next.x = size.width - next.width;
|
||||
}
|
||||
if (next.y + next.height > size.height) {
|
||||
next.y = size.height - next.height;
|
||||
}
|
||||
|
||||
next.x = clamp(next.x, 0, Math.max(0, size.width - next.width));
|
||||
next.y = clamp(next.y, 0, Math.max(0, size.height - next.height));
|
||||
|
||||
return next;
|
||||
}
|
||||
|
||||
function normalizeRegionCard(card, changedField) {
|
||||
if (!card) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var current = readCard(card);
|
||||
var next = {
|
||||
x: current.x,
|
||||
y: current.y,
|
||||
width: Math.max(12, Math.round(current.width || 0)),
|
||||
height: Math.max(12, Math.round(current.height || 0))
|
||||
};
|
||||
var lockRatio = normalizeLockRatio(current.lock_ratio);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
var size = getCanvasSize();
|
||||
|
||||
if (aspect) {
|
||||
if (changedField === 'height') {
|
||||
next.width = Math.max(12, Math.round(next.height * aspect.ratio));
|
||||
} else {
|
||||
next.height = Math.max(12, Math.round(next.width / aspect.ratio));
|
||||
}
|
||||
|
||||
if (next.width > size.width || next.height > size.height) {
|
||||
var scale = Math.min(size.width / Math.max(1, next.width), size.height / Math.max(1, next.height), 1);
|
||||
if (scale < 1) {
|
||||
next.width = Math.max(12, Math.round(next.width * scale));
|
||||
next.height = Math.max(12, Math.round(next.height * scale));
|
||||
if (changedField === 'height') {
|
||||
next.width = Math.max(12, Math.round(next.height * aspect.ratio));
|
||||
} else {
|
||||
next.height = Math.max(12, Math.round(next.width / aspect.ratio));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next = fitRegionToCanvas(next, aspect);
|
||||
writeCard(card, next);
|
||||
return next;
|
||||
}
|
||||
|
||||
function updateBackgroundPreview(file) {
|
||||
@@ -932,6 +1134,25 @@
|
||||
lockChip.textContent = 'Locked ' + lockRatio;
|
||||
}
|
||||
|
||||
function updateRegionLockRatioState(card) {
|
||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
var regionTypeInput = card.querySelector('[name="region_type[]"]');
|
||||
if (!lockRatioInput || !regionTypeInput) {
|
||||
return;
|
||||
}
|
||||
|
||||
var isQrCode = String(regionTypeInput.value || '').trim() === 'qr-code';
|
||||
lockRatioInput.disabled = isQrCode;
|
||||
lockRatioInput.setAttribute('aria-disabled', isQrCode ? 'true' : 'false');
|
||||
|
||||
if (isQrCode) {
|
||||
lockRatioInput.value = '1:1';
|
||||
lockRatioInput.title = 'QR code regions are fixed to 1:1.';
|
||||
} else {
|
||||
lockRatioInput.removeAttribute('title');
|
||||
}
|
||||
}
|
||||
|
||||
function getRegionLockRatio(card) {
|
||||
return normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value);
|
||||
}
|
||||
@@ -941,22 +1162,10 @@
|
||||
}
|
||||
|
||||
function syncLockedDimensions(card, changedField) {
|
||||
var lockRatio = getRegionLockRatio(card);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
if (!aspect) {
|
||||
return;
|
||||
}
|
||||
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
var heightInput = card.querySelector('[name="region_height[]"]');
|
||||
var width = Math.max(1, Number(widthInput.value || 0));
|
||||
var height = Math.max(1, Number(heightInput.value || 0));
|
||||
|
||||
if (changedField === 'width') {
|
||||
heightInput.value = Math.max(1, Math.round(width / aspect.ratio));
|
||||
} else if (changedField === 'height') {
|
||||
widthInput.value = Math.max(1, Math.round(height * aspect.ratio));
|
||||
}
|
||||
normalizeRegionCard(card, changedField);
|
||||
updateRegionLockBadge(card);
|
||||
markTemplateFormDirty();
|
||||
requestOverlayRender();
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
@@ -964,7 +1173,7 @@
|
||||
return window.pulseRegionTypes.getRegionChipLabel(regionType);
|
||||
}
|
||||
|
||||
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
return 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
@@ -995,7 +1204,7 @@
|
||||
}
|
||||
var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
if (regionLockRatioInput) {
|
||||
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio);
|
||||
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio || (String(region.region_type || '').trim() === 'qr-code' ? '1:1' : ''));
|
||||
}
|
||||
writeAnimationConfig(card, region.animation_json);
|
||||
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
|
||||
@@ -1003,6 +1212,7 @@
|
||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
||||
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
|
||||
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
|
||||
updateRegionLockRatioState(card);
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
|
||||
@@ -1053,6 +1263,7 @@
|
||||
var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
|
||||
var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
|
||||
nameInput.addEventListener('input', function () {
|
||||
regionNameValidationToastShown = false;
|
||||
syncRegionIdentity(card, nameInput.value);
|
||||
updateRegionLabel(card);
|
||||
validateRegionNames();
|
||||
@@ -1062,6 +1273,7 @@
|
||||
Array.prototype.forEach.call(animationPresetInputs, function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
|
||||
markTemplateFormDirty();
|
||||
requestOverlayRender();
|
||||
});
|
||||
});
|
||||
@@ -1069,6 +1281,7 @@
|
||||
button.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
setAnimationPreset(card, button.getAttribute('data-animation-reset-button'), 'none');
|
||||
markTemplateFormDirty();
|
||||
requestOverlayRender();
|
||||
});
|
||||
});
|
||||
@@ -1083,26 +1296,17 @@
|
||||
}
|
||||
if (lockRatioInput) {
|
||||
lockRatioInput.addEventListener('input', function () {
|
||||
updateRegionLockBadge(card);
|
||||
requestOverlayRender();
|
||||
syncLockedDimensions(card, 'width');
|
||||
});
|
||||
}
|
||||
if (widthInput) {
|
||||
widthInput.addEventListener('input', function () {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
requestOverlayRender();
|
||||
syncLockedDimensions(card, 'width');
|
||||
});
|
||||
}
|
||||
if (heightInput) {
|
||||
heightInput.addEventListener('input', function () {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'height');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
requestOverlayRender();
|
||||
syncLockedDimensions(card, 'height');
|
||||
});
|
||||
}
|
||||
card.addEventListener('click', function (event) {
|
||||
@@ -1114,6 +1318,7 @@
|
||||
card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
card.remove();
|
||||
markTemplateFormDirty();
|
||||
if (!getCards().length) {
|
||||
selectedIndex = -1;
|
||||
} else if (selectedIndex >= getCards().length) {
|
||||
@@ -1217,6 +1422,29 @@
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function observeStageResize() {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.ResizeObserver) {
|
||||
stageResizeObserver = new window.ResizeObserver(function () {
|
||||
requestOverlayRender();
|
||||
});
|
||||
stageResizeObserver.observe(stage);
|
||||
return;
|
||||
}
|
||||
|
||||
window.addEventListener('resize', requestOverlayRender);
|
||||
}
|
||||
|
||||
function markTemplateFormDirty() {
|
||||
if (!templateForm) {
|
||||
return;
|
||||
}
|
||||
templateForm.dataset.dirty = 'true';
|
||||
}
|
||||
|
||||
function setSelected(index) {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
@@ -1246,6 +1474,7 @@
|
||||
var card = makeRegionCard(region);
|
||||
setRegionRemovalState(card);
|
||||
regionList.appendChild(card);
|
||||
markTemplateFormDirty();
|
||||
setSelected(getCards().length - 1);
|
||||
}
|
||||
|
||||
@@ -1257,9 +1486,9 @@
|
||||
}
|
||||
|
||||
function createDefaultRegion(type) {
|
||||
var count = getCards().length + 1;
|
||||
var name = 'region_' + count;
|
||||
var size = getDefaultRegionSize(type, '');
|
||||
var name = getNextDefaultRegionName(type);
|
||||
var lockRatio = String(type || '').trim() === 'qr-code' ? '1:1' : '';
|
||||
var size = getDefaultRegionSize(type, lockRatio);
|
||||
return {
|
||||
region_key: name,
|
||||
label: name,
|
||||
@@ -1269,7 +1498,7 @@
|
||||
y: 80,
|
||||
width: size.width,
|
||||
height: size.height,
|
||||
lock_ratio: '',
|
||||
lock_ratio: lockRatio,
|
||||
z_index: 1
|
||||
};
|
||||
}
|
||||
@@ -1307,8 +1536,9 @@
|
||||
var width = Math.abs(draft.end.x - draft.start.x);
|
||||
var height = Math.abs(draft.end.y - draft.start.y);
|
||||
if (width >= 8 && height >= 8) {
|
||||
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
|
||||
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
|
||||
var region = fitRegionToCanvas({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
|
||||
var name = getNextDefaultRegionName('text');
|
||||
addRegion({ region_key: name, label: name, region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
|
||||
}
|
||||
draft = null;
|
||||
requestOverlayRender();
|
||||
@@ -1336,6 +1566,7 @@
|
||||
var nextX = clamp(startRegion.x + dx, 0, maxX);
|
||||
var nextY = clamp(startRegion.y + dy, 0, maxY);
|
||||
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
|
||||
markTemplateFormDirty();
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
@@ -1439,8 +1670,9 @@
|
||||
}
|
||||
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
|
||||
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
|
||||
next = clampRegion(next);
|
||||
next = fitRegionToCanvas(next, aspect);
|
||||
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
|
||||
markTemplateFormDirty();
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
@@ -1492,11 +1724,19 @@
|
||||
animationAdvancedModal.addEventListener('hidden.bs.modal', function () {
|
||||
activeAnimationCard = null;
|
||||
});
|
||||
animationAdvancedModal.addEventListener('input', function (event) {
|
||||
clampAnimationModalField(event.target);
|
||||
markTemplateFormDirty();
|
||||
});
|
||||
animationAdvancedModal.addEventListener('change', function () {
|
||||
markTemplateFormDirty();
|
||||
});
|
||||
var animationModalApplyButton = animationAdvancedModal.querySelector('[data-animation-modal-apply]');
|
||||
var animationModalResetButton = animationAdvancedModal.querySelector('[data-animation-modal-reset]');
|
||||
if (animationModalApplyButton) {
|
||||
animationModalApplyButton.addEventListener('click', function () {
|
||||
saveAnimationModal();
|
||||
markTemplateFormDirty();
|
||||
if (window.pulseModal) {
|
||||
window.pulseModal.hide(animationAdvancedModal);
|
||||
}
|
||||
@@ -1508,6 +1748,7 @@
|
||||
return;
|
||||
}
|
||||
writeAnimationConfig(activeAnimationCard, normalizeAnimationConfig('{}'));
|
||||
markTemplateFormDirty();
|
||||
populateAnimationModal(activeAnimationCard);
|
||||
requestOverlayRender();
|
||||
});
|
||||
@@ -1541,6 +1782,19 @@
|
||||
setSelected(-1);
|
||||
});
|
||||
if (templateForm) {
|
||||
templateForm.addEventListener('invalid', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.validationMessage || !target.validationMessage.match(/Region names must be unique on this template\.|Region name is required\./)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (target.closest && !target.closest('.region-item')) {
|
||||
return;
|
||||
}
|
||||
|
||||
notifyRegionNameValidationError(target.validationMessage);
|
||||
}, true);
|
||||
|
||||
templateForm.addEventListener('formdata', function (event) {
|
||||
if (previewState.active) {
|
||||
event.preventDefault();
|
||||
@@ -1552,16 +1806,13 @@
|
||||
return;
|
||||
}
|
||||
getCards().forEach(function (card) {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
normalizeRegionCard(card, 'width');
|
||||
});
|
||||
syncCanvasSizeSelection();
|
||||
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
|
||||
});
|
||||
|
||||
templateForm.addEventListener('submit', function () {
|
||||
templateForm.addEventListener('submit', function (event) {
|
||||
if (previewState.active) {
|
||||
return;
|
||||
}
|
||||
@@ -1570,10 +1821,7 @@
|
||||
return;
|
||||
}
|
||||
getCards().forEach(function (card) {
|
||||
if (isRegionLocked(card)) {
|
||||
syncLockedDimensions(card, 'width');
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
normalizeRegionCard(card, 'width');
|
||||
});
|
||||
syncCanvasSizeSelection();
|
||||
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
|
||||
@@ -1613,5 +1861,6 @@
|
||||
renderRegionList(existingRegions);
|
||||
syncCanvasSizeSelection();
|
||||
updateStageBackgroundColor();
|
||||
observeStageResize();
|
||||
render();
|
||||
})();
|
||||
|
||||
@@ -1,4 +1,63 @@
|
||||
(function (root) {
|
||||
var pendingToastStorageKey = 'pulse:pending-toast';
|
||||
|
||||
function readPendingToast() {
|
||||
try {
|
||||
if (!root.sessionStorage) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var rawValue = root.sessionStorage.getItem(pendingToastStorageKey);
|
||||
if (!rawValue) {
|
||||
return null;
|
||||
}
|
||||
|
||||
root.sessionStorage.removeItem(pendingToastStorageKey);
|
||||
var payload = JSON.parse(rawValue);
|
||||
if (!payload || !payload.message) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
message: String(payload.message || '').trim(),
|
||||
variant: String(payload.variant || 'info').trim().toLowerCase() || 'info'
|
||||
};
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writePendingToast(message, variant) {
|
||||
try {
|
||||
if (!root.sessionStorage) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return false;
|
||||
}
|
||||
|
||||
root.sessionStorage.setItem(pendingToastStorageKey, JSON.stringify({
|
||||
message: text,
|
||||
variant: String(variant || 'info').trim().toLowerCase() || 'info'
|
||||
}));
|
||||
return true;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function clearPendingToast() {
|
||||
try {
|
||||
if (root.sessionStorage) {
|
||||
root.sessionStorage.removeItem(pendingToastStorageKey);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore storage cleanup failures
|
||||
}
|
||||
}
|
||||
|
||||
function getToastTitle(variant, message) {
|
||||
var text = String(message || '').trim();
|
||||
if (/\b(?:deleted|removed)\.?$/i.test(text)) {
|
||||
@@ -219,9 +278,15 @@
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
if (!toast) {
|
||||
var pendingToast = readPendingToast();
|
||||
if (pendingToast) {
|
||||
showToast(pendingToast.message, pendingToast.variant);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
clearPendingToast();
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (url.searchParams.has('message')) {
|
||||
@@ -250,6 +315,7 @@
|
||||
root.showToast = showToast;
|
||||
root.initToast = initToast;
|
||||
root.getMessageVariant = getMessageVariant;
|
||||
root.queueToastAfterRefresh = writePendingToast;
|
||||
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = {
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
function formatDashboardDate(value, useTwentyFourHour) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
@@ -20,14 +20,20 @@
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
var formatOptions = {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
};
|
||||
|
||||
if (useTwentyFourHour) {
|
||||
formatOptions.hour12 = false;
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', formatOptions).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
@@ -85,6 +91,206 @@
|
||||
return ip;
|
||||
}
|
||||
|
||||
window.escapeHtml = escapeHtml;
|
||||
|
||||
var defaultQrOptions = window.defaultQrOptions || {
|
||||
width: 1000,
|
||||
height: 1000,
|
||||
type: 'canvas',
|
||||
margin: 10,
|
||||
qrOptions: {},
|
||||
dotsOptions: {
|
||||
color: '#000000',
|
||||
type: 'square'
|
||||
},
|
||||
cornersSquareOptions: {
|
||||
color: '#000000',
|
||||
type: 'square'
|
||||
},
|
||||
cornersDotOptions: {
|
||||
color: '#000000',
|
||||
type: 'square'
|
||||
},
|
||||
backgroundOptions: {
|
||||
color: '#ffffff'
|
||||
}
|
||||
};
|
||||
var qrDotTypes = window.qrDotTypes || [
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'dots', label: 'Dots' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'extra-rounded', label: 'Extra rounded' },
|
||||
{ value: 'classy', label: 'Classy' },
|
||||
{ value: 'classy-rounded', label: 'Classy rounded' }
|
||||
];
|
||||
var qrCornerSquareTypes = window.qrCornerSquareTypes || [
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'dot', label: 'Dot' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'extra-rounded', label: 'Extra rounded' },
|
||||
{ value: 'dots', label: 'Dots' },
|
||||
{ value: 'classy', label: 'Classy' },
|
||||
{ value: 'classy-rounded', label: 'Classy rounded' }
|
||||
];
|
||||
var qrCornerDotTypes = window.qrCornerDotTypes || [
|
||||
{ value: 'square', label: 'Square' },
|
||||
{ value: 'dot', label: 'Dot' },
|
||||
{ value: 'rounded', label: 'Rounded' },
|
||||
{ value: 'extra-rounded', label: 'Extra rounded' },
|
||||
{ value: 'classy', label: 'Classy' },
|
||||
{ value: 'classy-rounded', label: 'Classy rounded' }
|
||||
];
|
||||
|
||||
function getOptionValue(option) {
|
||||
if (option && typeof option === 'object' && option.value !== undefined && option.value !== null) {
|
||||
return String(option.value).trim();
|
||||
}
|
||||
|
||||
return String(option === undefined || option === null ? '' : option).trim();
|
||||
}
|
||||
|
||||
function normalizeMarginValue(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
function normalizeHexColorValue(value, fallback) {
|
||||
var raw = String(value === undefined || value === null ? '' : value).trim();
|
||||
return raw || fallback;
|
||||
}
|
||||
|
||||
function normalizeBooleanValue(value, fallback) {
|
||||
if (value === undefined || value === null || value === '') {
|
||||
return Boolean(fallback);
|
||||
}
|
||||
if (typeof value === 'boolean') {
|
||||
return value;
|
||||
}
|
||||
var text = String(value).trim().toLowerCase();
|
||||
if (text === 'true' || text === '1' || text === 'yes' || text === 'on') {
|
||||
return true;
|
||||
}
|
||||
if (text === 'false' || text === '0' || text === 'no' || text === 'off') {
|
||||
return false;
|
||||
}
|
||||
return Boolean(value);
|
||||
}
|
||||
|
||||
function normalizeOptionValue(value, options, fallback) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim();
|
||||
for (var index = 0; index < options.length; index += 1) {
|
||||
if (getOptionValue(options[index]) === text) {
|
||||
return text;
|
||||
}
|
||||
}
|
||||
|
||||
return fallback;
|
||||
}
|
||||
|
||||
function normalizeRatioValue(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return Number.isFinite(parsed) && parsed >= 0 ? parsed : fallback;
|
||||
}
|
||||
|
||||
function normalizeGradientTypeValue(value, fallback) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
|
||||
return text === 'radial' ? 'radial' : 'linear';
|
||||
}
|
||||
|
||||
function normalizeGradientRotationValue(value, fallback) {
|
||||
var parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? Math.round(parsed) : fallback;
|
||||
}
|
||||
|
||||
function normalizeColorModeValue(value, fallback) {
|
||||
var text = String(value === undefined || value === null ? '' : value).trim().toLowerCase();
|
||||
return text === 'gradient' ? 'gradient' : 'single';
|
||||
}
|
||||
|
||||
function normalizeColorValue(value, fallback) {
|
||||
return normalizeHexColorValue(value, fallback);
|
||||
}
|
||||
|
||||
function renderSelectOptions(options, currentValue) {
|
||||
return options.map(function (option) {
|
||||
var optionValue = getOptionValue(option);
|
||||
var optionLabel = option && typeof option === 'object' && option.label !== undefined ? option.label : optionValue;
|
||||
return '<option value="' + escapeHtml(optionValue) + '"' + (String(optionValue) === String(currentValue) ? ' selected' : '') + '>' + escapeHtml(optionLabel) + '</option>';
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function normalizeSvgMarkup(svg) {
|
||||
var markup = String(svg || '').trim();
|
||||
if (!markup || markup.charAt(0) !== '<') {
|
||||
return markup;
|
||||
}
|
||||
|
||||
return markup.replace(/^<svg\b([^>]*)>/i, function (_match, attrText) {
|
||||
var attrs = String(attrText || '');
|
||||
if (!/\bwidth\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' width="95%"';
|
||||
}
|
||||
if (!/\bheight\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' height="95%"';
|
||||
}
|
||||
if (!/\bpreserveAspectRatio\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' preserveAspectRatio="xMidYMid meet"';
|
||||
}
|
||||
if (!/\bstyle\s*=\s*/i.test(attrs)) {
|
||||
attrs += ' style="display:block;width:95%;height:95%;"';
|
||||
}
|
||||
return '<svg' + attrs + '>';
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeQrSvg(svg) {
|
||||
return normalizeSvgMarkup(svg);
|
||||
}
|
||||
|
||||
async function blobToText(blob) {
|
||||
if (!blob) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (typeof blob.text === 'function') {
|
||||
return blob.text();
|
||||
}
|
||||
|
||||
if (typeof FileReader === 'function') {
|
||||
return await new Promise(function (resolve, reject) {
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
resolve(String(reader.result || ''));
|
||||
};
|
||||
reader.onerror = function () {
|
||||
reject(reader.error || new Error('Failed to read blob.'));
|
||||
};
|
||||
reader.readAsText(blob);
|
||||
});
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
window.defaultQrOptions = defaultQrOptions;
|
||||
window.qrDotTypes = qrDotTypes;
|
||||
window.qrCornerSquareTypes = qrCornerSquareTypes;
|
||||
window.qrCornerDotTypes = qrCornerDotTypes;
|
||||
window.normalizeMarginValue = normalizeMarginValue;
|
||||
window.normalizeHexColorValue = normalizeHexColorValue;
|
||||
window.normalizeBooleanValue = normalizeBooleanValue;
|
||||
window.normalizeOptionValue = normalizeOptionValue;
|
||||
window.normalizeRatioValue = normalizeRatioValue;
|
||||
window.normalizeGradientTypeValue = normalizeGradientTypeValue;
|
||||
window.normalizeGradientRotationValue = normalizeGradientRotationValue;
|
||||
window.normalizeColorModeValue = normalizeColorModeValue;
|
||||
window.normalizeColorValue = normalizeColorValue;
|
||||
window.renderSelectOptions = renderSelectOptions;
|
||||
window.normalizeSvgMarkup = normalizeSvgMarkup;
|
||||
window.normalizeQrSvg = normalizeQrSvg;
|
||||
window.blobToText = blobToText;
|
||||
window.pendingByRegionId = window.pendingByRegionId || Object.create(null);
|
||||
|
||||
window.webUiHelpers = {
|
||||
escapeHtml: escapeHtml,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
(function () {
|
||||
if (window.QRCodeStyling) {
|
||||
return;
|
||||
}
|
||||
|
||||
var request = new XMLHttpRequest();
|
||||
request.open('GET', '/assets/vendor/qr-code-styling/qr-code-styling.js', false);
|
||||
request.send(null);
|
||||
|
||||
if (request.status < 200 || request.status >= 300) {
|
||||
throw new Error('Failed to load QR code styling library.');
|
||||
}
|
||||
|
||||
var source = String(request.responseText || '');
|
||||
var initialize = new Function(source + '\nreturn typeof QRCodeStyling !== \'undefined\' ? QRCodeStyling : window.QRCodeStyling;');
|
||||
var exported = initialize.call(window);
|
||||
|
||||
if (exported && !window.QRCodeStyling) {
|
||||
window.QRCodeStyling = exported;
|
||||
}
|
||||
}());
|
||||
File diff suppressed because one or more lines are too long
@@ -8,6 +8,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const createUserSession = deps.createUserSession;
|
||||
const setSessionCookie = deps.setSessionCookie;
|
||||
|
||||
@@ -52,8 +53,9 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
if (!verifyPassword(currentPassword, user)) {
|
||||
return res.status(400).send('Current password is incorrect.');
|
||||
}
|
||||
if (!newPassword || newPassword.length < 8) {
|
||||
return res.status(400).send('New password must be at least 8 characters.');
|
||||
const passwordStrengthMessage = validatePasswordStrength(newPassword);
|
||||
if (passwordStrengthMessage) {
|
||||
return res.status(400).send(passwordStrengthMessage);
|
||||
}
|
||||
if (newPassword !== confirmPassword) {
|
||||
return res.status(400).send('New passwords do not match.');
|
||||
@@ -68,7 +70,7 @@ module.exports = function registerAccountRoutes(app, deps) {
|
||||
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/dashboard?message=' + encodeURIComponent('Password updated.'));
|
||||
res.redirect('/account?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -29,6 +29,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
const requirePermission = deps.requirePermission;
|
||||
const hasAnyPermission = deps.hasAnyPermission;
|
||||
const { buildPagination } = require('#src/web/lib/pagination');
|
||||
const { buildDuplicateSlideName, buildDuplicateSlide } = require('#src/web/routes/signage/slides/duplicate');
|
||||
const { buildDuplicateCanvasSizeName, buildDuplicateCanvasSize } = require('#src/web/routes/signage/canvas-sizes/duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
@@ -163,6 +165,11 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchCanvasSizeUsageCount(canvasSizeId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSizeId]);
|
||||
return Number(rows[0] && rows[0].ref_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchTemplateRegionUsage(template) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE template_id = ?', [template.id]);
|
||||
const usage = new Set();
|
||||
@@ -188,6 +195,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
|
||||
return Array.from(usage);
|
||||
}
|
||||
const { buildDuplicateTemplateName, buildDuplicateTemplate } = require('../signage/templates/duplicate');
|
||||
|
||||
async function fetchSlidesByTemplateId(templateId) {
|
||||
const [slides] = await pool.query('SELECT id, thumbnail_path FROM c_slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
||||
@@ -201,6 +209,27 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
|
||||
}
|
||||
|
||||
|
||||
app.get('/templates/:id/duplicate', requirePermission('templates.read'), requirePermission('templates.create'), async function (req, res, next) {
|
||||
try {
|
||||
const template = await common.fetchTemplateById(pool, Number(req.params.id));
|
||||
if (!template) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
|
||||
const canvasSizes = await common.fetchCanvasSizesData(pool);
|
||||
let duplicateName = buildDuplicateTemplateName(template.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_templates', duplicateName)) {
|
||||
duplicateName = buildDuplicateTemplateName(template.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderTemplateAddPage(buildDuplicateTemplate(template, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', canvasSizes.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
||||
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
||||
if (!removedRegionKeys.length) {
|
||||
@@ -306,6 +335,27 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/:id/duplicate', requirePermission('slides.read'), requirePermission('slides.create'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
}
|
||||
|
||||
const data = await fetchSlideFormData();
|
||||
let duplicateName = buildDuplicateSlideName(slide.title);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_slides', duplicateName, null, 'title')) {
|
||||
duplicateName = buildDuplicateSlideName(slide.title) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderSlideAddPage(buildDuplicateSlide(slide, duplicateName), data, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/slides/:id/edit', requirePermission('slides.update'), async function (req, res, next) {
|
||||
try {
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
@@ -342,12 +392,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
try {
|
||||
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
|
||||
? req.body.uploadPaths
|
||||
: Array.isArray(req.body && req.body.paths)
|
||||
? req.body.paths
|
||||
: req.body && req.body.uploadPaths
|
||||
? [req.body.uploadPaths]
|
||||
: [];
|
||||
|
||||
if (!uploadPaths.length) {
|
||||
return res.status(400).json({ error: 'No upload paths were provided.' });
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
|
||||
res.json({ ok: true });
|
||||
res.sendStatus(204);
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -639,6 +693,26 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
res.send(pages.renderCanvasSizeAddPage(null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
});
|
||||
|
||||
app.get('/canvas-sizes/:id/duplicate', requirePermission('canvas-sizes.read'), requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const canvasSize = await common.fetchCanvasSizeById(pool, Number(req.params.id));
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateCanvasSizeName(canvasSize.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_canvas_sizes', duplicateName)) {
|
||||
duplicateName = buildDuplicateCanvasSizeName(canvasSize.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderCanvasSizeAddPage(buildDuplicateCanvasSize(canvasSize, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/canvas-sizes', requirePermission('canvas-sizes.create'), async function (req, res, next) {
|
||||
try {
|
||||
const payload = common.buildCanvasSizePayload(req, null);
|
||||
@@ -646,7 +720,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height)) {
|
||||
return res.redirect('/canvas-sizes/new?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.status(400).send('That canvas size already exists.');
|
||||
}
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query('INSERT INTO c_canvas_sizes (name, width, height, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [payload.name, payload.width, payload.height, actorId, actorId]);
|
||||
@@ -666,8 +740,7 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS ref_count FROM c_templates WHERE canvas_size_id = ?', [canvasSize.id]);
|
||||
canvasSize.inUse = Number(rows[0] && rows[0].ref_count) > 0;
|
||||
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
||||
res.send(pages.renderCanvasSizeEditPage(canvasSize, null, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
@@ -680,12 +753,16 @@ module.exports = function registerContentRoutes(app, deps) {
|
||||
if (!canvasSize) {
|
||||
return res.status(404).send('Canvas size not found');
|
||||
}
|
||||
canvasSize.inUse = (await fetchCanvasSizeUsageCount(canvasSize.id)) > 0;
|
||||
const payload = common.buildCanvasSizePayload(req, canvasSize);
|
||||
if (canvasSize.inUse && (payload.width !== Number(canvasSize.width) || payload.height !== Number(canvasSize.height))) {
|
||||
return res.status(400).send('This canvas size is still used by one or more templates, so its dimensions cannot be changed.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'c_canvas_sizes', payload.name, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('A canvas size with that name already exists.'));
|
||||
}
|
||||
if (await canvasSizeExists(payload.width, payload.height, canvasSize.id)) {
|
||||
return res.redirect('/canvas-sizes/' + canvasSize.id + '/edit?message=' + encodeURIComponent('That canvas size already exists.'));
|
||||
return res.status(400).send('That canvas size already exists.');
|
||||
}
|
||||
await pool.query('UPDATE c_canvas_sizes SET name = ?, width = ?, height = ?, modified_by = ? WHERE id = ?', [payload.name, payload.width, payload.height, getAuditUserId(req), canvasSize.id]);
|
||||
redirectAfterSave(req, res, '/canvas-sizes', {
|
||||
|
||||
@@ -14,6 +14,18 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const PLAYER_PUBLIC_BASE_URL = deps.playerPublicBaseUrl;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
const SCREEN_NAME_MAX_LENGTH = 255;
|
||||
const SCREEN_SLUG_MAX_LENGTH = 255;
|
||||
|
||||
function readLimitedText(value, maxLength) {
|
||||
const text = String(value || '').trim();
|
||||
const limit = Number(maxLength);
|
||||
if (!Number.isFinite(limit) || limit < 1 || text.length <= limit) {
|
||||
return text;
|
||||
}
|
||||
return text.slice(0, limit);
|
||||
}
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
const [rows] = await pool.query('SELECT slug FROM d_screens ORDER BY slug ASC');
|
||||
return (rows || [])
|
||||
@@ -85,14 +97,18 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
|
||||
app.post('/screens', requirePermission('screens.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
||||
: readLimitedText(req.body.name || '', 255);
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const slugInput = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
||||
: readLimitedText(req.body.slug || '', 255);
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name));
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -109,7 +125,9 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
|
||||
app.post('/screens/:id', requirePermission('screens.update'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.name || '', SCREEN_NAME_MAX_LENGTH, 'Screen name')
|
||||
: readLimitedText(req.body.name || '', 255);
|
||||
if (!name) {
|
||||
return res.status(400).send('Screen name is required.');
|
||||
}
|
||||
@@ -120,7 +138,9 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
if (await common.fetchDuplicateName(pool, 'd_screens', name, screen.id)) {
|
||||
return res.status(400).send('A screen with that name already exists.');
|
||||
}
|
||||
const slugInput = String(req.body.slug || '').trim();
|
||||
const slugInput = typeof common.validateMaxLength === 'function'
|
||||
? common.validateMaxLength(req.body.slug || '', SCREEN_SLUG_MAX_LENGTH, 'Screen URL')
|
||||
: readLimitedText(req.body.slug || '', 255);
|
||||
const playlistId = req.body.playlist_id ? Number(req.body.playlist_id) : null;
|
||||
const previousPlaylistId = screen.playlist_id;
|
||||
const slug = await common.uniqueScreenSlug(pool, common.slugify(slugInput || name), screen.id);
|
||||
|
||||
@@ -11,12 +11,17 @@
|
||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicateRoleName, buildDuplicateRole } = require('../settings/rbac/duplicate');
|
||||
|
||||
const ROLE_NAME_MAX_LENGTH = 255;
|
||||
const ROLE_DESCRIPTION_MAX_LENGTH = 255;
|
||||
const ROLE_KEY_MAX_LENGTH = 100;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
function slugifyRoleKey(name) {
|
||||
const value = String(name || '').trim().toLowerCase();
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, ROLE_KEY_MAX_LENGTH);
|
||||
return slug || 'role';
|
||||
}
|
||||
|
||||
@@ -65,10 +70,12 @@
|
||||
return (Array.isArray(permissionRows) ? permissionRows : []).map(function (permission) {
|
||||
const definition = permissionDefinitions.get(String(permission.permission_key || '').trim()) || null;
|
||||
return Object.assign({}, permission, {
|
||||
resourceKey: definition ? definition.sectionKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.name : permission.section_name,
|
||||
resourceKey: definition ? definition.resourceKey : String(permission.section_name || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-'),
|
||||
resourceName: definition ? definition.resourceName : permission.section_name,
|
||||
categoryName: definition ? definition.sectionName : permission.section_name,
|
||||
sectionOrder: definition ? definition.sectionOrder : 999,
|
||||
resourceOrder: definition ? Number(definition.resourceOrder) || 999 : 999,
|
||||
permissionOrder: definition ? Number(definition.permissionOrder) || 999 : 999,
|
||||
actionKey: definition ? definition.actionKey : 'read',
|
||||
actionLabel: definition ? definition.actionName : getActionLabel(permission.actionKey),
|
||||
isSelected: selectedKeys.has(String(permission.permission_key || '').trim())
|
||||
@@ -107,9 +114,8 @@
|
||||
|
||||
groups.forEach(function (group) {
|
||||
group.permissions.sort(function (left, right) {
|
||||
const actionOrder = { create: 1, read: 2, update: 3, edit: 3, delete: 4, allow: 5 };
|
||||
const leftOrder = actionOrder[String(left.actionKey || '').trim()] || 99;
|
||||
const rightOrder = actionOrder[String(right.actionKey || '').trim()] || 99;
|
||||
const leftOrder = Number(left.permissionOrder) || 999;
|
||||
const rightOrder = Number(right.permissionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
@@ -136,8 +142,8 @@
|
||||
});
|
||||
return {
|
||||
formValues: {
|
||||
name: String(formValues && formValues.name || '').trim(),
|
||||
description: String(formValues && formValues.description || '').trim()
|
||||
name: common.validateMaxLength(formValues && formValues.name || '', ROLE_NAME_MAX_LENGTH, 'Role name'),
|
||||
description: common.validateMaxLength(formValues && formValues.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description')
|
||||
},
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(data.users, selectedUserIds),
|
||||
@@ -197,10 +203,39 @@
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/rbac/:id/duplicate', requirePermission('rbac.read'), requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const roleId = Number(req.params.id);
|
||||
if (!Number.isInteger(roleId) || roleId <= 0) {
|
||||
return res.status(400).send('Invalid role.');
|
||||
}
|
||||
|
||||
const role = await rbacData.fetchRoleById(pool, roleId);
|
||||
if (!role) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
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 permissionKeys = await rbacData.fetchRolePermissionKeys(pool, roleId);
|
||||
const viewModel = await buildRoleCreateViewModel({
|
||||
name: buildDuplicateRoleName(role.name),
|
||||
description: buildDuplicateRole(role).description
|
||||
}, permissionKeys, [], page, search, sort, direction, currentUserId);
|
||||
|
||||
res.send(pages.renderRbacAddPage('Review the copied values and save when ready.', req.currentUser, viewModel.formValues, viewModel.permissionGroups, viewModel.users, viewModel.pagination, 'primary'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/rbac', requirePermission('rbac.create'), async function (req, res, next) {
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const selectedUserIds = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present')
|
||||
? readArrayField(req.body, ['user_ids[]', 'user_ids'])
|
||||
: [];
|
||||
@@ -311,8 +346,8 @@
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
const name = String(req.body.name || '').trim();
|
||||
const description = String(req.body.description || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', ROLE_NAME_MAX_LENGTH, 'Role name');
|
||||
const description = common.validateMaxLength(req.body.description || '', ROLE_DESCRIPTION_MAX_LENGTH, 'Role description');
|
||||
const shouldSyncPermissions = Object.prototype.hasOwnProperty.call(req.body || {}, 'permissions_present');
|
||||
const shouldSyncUsers = Object.prototype.hasOwnProperty.call(req.body || {}, 'users_present');
|
||||
const selectedPermissionKeys = shouldSyncPermissions
|
||||
|
||||
@@ -7,10 +7,15 @@
|
||||
const formatDashboardDate = deps.formatDashboardDate;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const hashPassword = deps.hashPassword;
|
||||
const validatePasswordStrength = deps.validatePasswordStrength;
|
||||
const readArrayField = deps.readArrayField;
|
||||
const rbacData = deps.rbacData;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicateUserName, buildDuplicateUser } = require('../settings/users/duplicate');
|
||||
|
||||
const USER_NAME_MAX_LENGTH = 255;
|
||||
const USER_USERNAME_MAX_LENGTH = 255;
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
@@ -43,10 +48,6 @@
|
||||
return Number.isInteger(roleId) && roleId > 0;
|
||||
})));
|
||||
|
||||
if (!normalizedRoleIds.length) {
|
||||
return { ok: false, message: 'Select at least one role.' };
|
||||
}
|
||||
|
||||
if (normalizedRoleIds.some(function (roleId) {
|
||||
return !validRoleIds.has(roleId);
|
||||
})) {
|
||||
@@ -88,6 +89,38 @@
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/users/:id/duplicate', requirePermission('users.read'), requirePermission('users.create'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
}
|
||||
|
||||
const user = await rbacData.fetchUserWithRoles(pool, userId);
|
||||
if (!user) {
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Use Add user to create another login for yourself.'));
|
||||
}
|
||||
|
||||
const roles = await fetchRoleOptions();
|
||||
const duplicateUser = buildDuplicateUser(user, buildDuplicateUserName(user.name));
|
||||
|
||||
res.send(pages.renderUsersAddPage(
|
||||
'Review the copied values and save when ready.',
|
||||
req.currentUser,
|
||||
mapRolesForForm(roles, user.roleIds),
|
||||
{
|
||||
name: duplicateUser.name
|
||||
},
|
||||
'primary'
|
||||
));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/users/:id/edit', requirePermission('users.update'), async function (req, res, next) {
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
@@ -122,10 +155,11 @@
|
||||
app.post('/users', requirePermission('users.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const name = String(req.body.name || '').trim();
|
||||
const username = String(req.body.username || user.username || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
||||
const username = common.validateMaxLength(req.body.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
|
||||
const selectedRoleIds = readArrayField(req.body, ['role_ids[]', 'role_ids']);
|
||||
const roleCheck = await validateRoleIds(selectedRoleIds);
|
||||
const formValues = {
|
||||
@@ -144,8 +178,9 @@
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
if (passwordStrengthMessage) {
|
||||
return renderValidationError(passwordStrengthMessage);
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
@@ -171,6 +206,9 @@
|
||||
);
|
||||
await rbacData.syncUserRoles(connection, result.insertId, roleCheck.roleIds);
|
||||
await connection.commit();
|
||||
if (saveAction === 'new') {
|
||||
return res.redirect('/users/new?message=' + encodeURIComponent('User created.'));
|
||||
}
|
||||
res.redirect('/users/' + result.insertId + '/edit?message=' + encodeURIComponent('User created.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -216,11 +254,12 @@
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
const userId = Number(req.params.id);
|
||||
const name = String(req.body.name || '').trim();
|
||||
const name = common.validateMaxLength(req.body.name || '', USER_NAME_MAX_LENGTH, 'Name');
|
||||
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);
|
||||
const saveAction = String(req.body.save_action || req.body.action || '').trim().toLowerCase();
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
@@ -234,7 +273,7 @@
|
||||
return res.status(404).send('User not found.');
|
||||
}
|
||||
|
||||
const username = String(req.body.username || user.username || '').trim();
|
||||
const username = common.validateMaxLength(req.body.username || user.username || '', USER_USERNAME_MAX_LENGTH, 'Username');
|
||||
|
||||
const [countRows] = await pool.query('SELECT COUNT(*) AS user_count FROM a_users');
|
||||
const canDelete = !countRows.length || Number(countRows[0].user_count) > 1;
|
||||
@@ -256,8 +295,11 @@
|
||||
if (!username) {
|
||||
return renderValidationError('Username is required.');
|
||||
}
|
||||
if (shouldUpdatePassword && (!password || password.length < 8)) {
|
||||
return renderValidationError('Password must be at least 8 characters.');
|
||||
if (shouldUpdatePassword) {
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
if (passwordStrengthMessage) {
|
||||
return renderValidationError(passwordStrengthMessage);
|
||||
}
|
||||
}
|
||||
if (shouldUpdatePassword && password !== confirmPassword) {
|
||||
return renderValidationError('Passwords do not match.');
|
||||
@@ -290,6 +332,9 @@
|
||||
await connection.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
}
|
||||
await connection.commit();
|
||||
if (saveAction === 'new') {
|
||||
return res.redirect('/users/new?message=' + encodeURIComponent('User updated.'));
|
||||
}
|
||||
res.redirect('/users?message=' + encodeURIComponent('User updated.'));
|
||||
} catch (error) {
|
||||
try {
|
||||
@@ -308,6 +353,7 @@
|
||||
const userId = Number(req.params.id);
|
||||
const password = String(req.body.password || '');
|
||||
const confirmPassword = String(req.body.confirm_password || '');
|
||||
const editUrl = '/users/' + userId + '/edit';
|
||||
|
||||
if (!Number.isInteger(userId) || userId <= 0) {
|
||||
return res.status(400).send('Invalid user.');
|
||||
@@ -315,11 +361,12 @@
|
||||
if (Number(req.currentUser.id) === userId) {
|
||||
return res.redirect('/account?message=' + encodeURIComponent('Use My Account to change your own password.'));
|
||||
}
|
||||
if (!password || password.length < 8) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Password must be at least 8 characters.'));
|
||||
const passwordStrengthMessage = validatePasswordStrength(password);
|
||||
if (passwordStrengthMessage) {
|
||||
return res.redirect(editUrl + '?message=' + encodeURIComponent(passwordStrengthMessage));
|
||||
}
|
||||
if (password !== confirmPassword) {
|
||||
return res.redirect('/users?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
return res.redirect(editUrl + '?message=' + encodeURIComponent('Passwords do not match.'));
|
||||
}
|
||||
|
||||
const [rows] = await pool.query('SELECT id FROM a_users WHERE id = ? LIMIT 1', [userId]);
|
||||
@@ -333,7 +380,7 @@
|
||||
[passwordRecord.hash, passwordRecord.salt, passwordRecord.iterations, getAuditUserId(req), userId]
|
||||
);
|
||||
await pool.query('DELETE FROM a_sessions WHERE user_id = ?', [userId]);
|
||||
res.redirect('/users?message=' + encodeURIComponent('Password updated.'));
|
||||
res.redirect(editUrl + '?message=' + encodeURIComponent('Password updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
// Authentication route registration for the web app.
|
||||
|
||||
const { normalizeReturnToPath, getRequestOrigin } = require('../../lib/auth/session');
|
||||
|
||||
module.exports = function registerAuthRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const pages = deps.pages;
|
||||
@@ -13,19 +15,28 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const verifyPassword = deps.verifyPassword;
|
||||
const sessionCookieName = deps.sessionCookieName;
|
||||
|
||||
function getReturnTo(req) {
|
||||
return normalizeReturnToPath(req && (req.query && req.query.returnTo || req.body && req.body.returnTo), getRequestOrigin(req));
|
||||
}
|
||||
|
||||
function buildLoginRedirect(returnTo) {
|
||||
return returnTo ? '/login?returnTo=' + encodeURIComponent(returnTo) : '/login';
|
||||
}
|
||||
|
||||
app.get('/', function (req, res) {
|
||||
res.redirect(req.currentUser ? '/dashboard' : '/login');
|
||||
});
|
||||
|
||||
app.get('/login', function (req, res) {
|
||||
const returnTo = getReturnTo(req);
|
||||
if (req.currentUser) {
|
||||
return res.redirect('/dashboard');
|
||||
return res.redirect(returnTo || '/dashboard');
|
||||
}
|
||||
|
||||
const message = typeof consumeAuthMessageCookie === 'function'
|
||||
? consumeAuthMessageCookie(req, res)
|
||||
: (req.query.message ? String(req.query.message) : '');
|
||||
res.send(pages.renderLoginPage(message));
|
||||
res.send(pages.renderLoginPage(message, returnTo));
|
||||
});
|
||||
|
||||
app.post('/login', async function (req, res, next) {
|
||||
@@ -39,17 +50,19 @@ module.exports = function registerAuthRoutes(app, deps) {
|
||||
const [rows] = await pool.query('SELECT id, name, username, password_hash, password_salt, password_iterations FROM a_users WHERE username = ? LIMIT 1', [username]);
|
||||
const user = rows[0] || null;
|
||||
if (!user || !verifyPassword(password, user)) {
|
||||
const returnTo = getReturnTo(req);
|
||||
if (typeof setAuthMessageCookie === 'function') {
|
||||
setAuthMessageCookie(res, 'Invalid username or password.');
|
||||
return res.redirect('/login');
|
||||
return res.redirect(buildLoginRedirect(returnTo));
|
||||
}
|
||||
|
||||
return res.status(401).send(pages.renderLoginPage('Invalid username or password.'));
|
||||
return res.status(401).send(pages.renderLoginPage('Invalid username or password.', returnTo));
|
||||
}
|
||||
|
||||
const returnTo = getReturnTo(req);
|
||||
const token = await createUserSession(pool, user.id);
|
||||
setSessionCookie(res, token);
|
||||
res.redirect('/dashboard');
|
||||
res.redirect(returnTo || '/dashboard');
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
const { renderView } = require('../../view');
|
||||
|
||||
module.exports = function renderLoginPage(message) {
|
||||
module.exports = function renderLoginPage(message, returnTo) {
|
||||
return renderView('auth/login', {
|
||||
title: 'Sign in',
|
||||
authShell: true,
|
||||
bodyClass: 'login-page-body',
|
||||
message: message || '',
|
||||
returnTo: returnTo || '',
|
||||
messageVariant: 'warning'
|
||||
});
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
// API source duplication helpers.
|
||||
|
||||
function buildDuplicateApiSourceName(sourceName) {
|
||||
const baseName = 'Copy of ' + String(sourceName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateApiSource(apiSource, duplicateName) {
|
||||
return Object.assign({}, apiSource, {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
apiUrl: apiSource.api_url || '',
|
||||
lastPulledAt: null,
|
||||
lastPullError: '',
|
||||
lastResponseStatus: null,
|
||||
lastResponseContentType: '',
|
||||
lastResponseJson: '',
|
||||
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 || ''
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateApiSourceName,
|
||||
buildDuplicateApiSource
|
||||
};
|
||||
@@ -35,8 +35,9 @@ function buildApiSourceFormViewModel(apiSource, message, currentUser, options, i
|
||||
inUse: Boolean(viewApiSource.inUse),
|
||||
lastResponseJson: viewOptions.lastResponseJson || viewApiSource.lastResponseJson || '',
|
||||
lastPullError: viewOptions.lastPullError || viewApiSource.lastPullError || '',
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
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"',
|
||||
|
||||
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderApiSourcesPage = require('./list');
|
||||
const renderApiSourceAddPage = require('./add');
|
||||
const renderApiSourceEditPage = require('./edit');
|
||||
const { buildDuplicateApiSourceName, buildDuplicateApiSource } = require('./duplicate');
|
||||
|
||||
function toIsoTimestamp(value) {
|
||||
if (!value) {
|
||||
@@ -185,6 +186,29 @@ module.exports = function registerApiSourceRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/api-sources/:id/duplicate', requirePermission('api-sources.read'), requirePermission('api-sources.create'), 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');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateApiSourceName(apiSource.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_api_sources', duplicateName)) {
|
||||
duplicateName = buildDuplicateApiSourceName(apiSource.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderApiSourceAddPage(buildDuplicateApiSource(apiSource, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
messageVariant: 'info',
|
||||
showSaveSecondaryActions: true
|
||||
}));
|
||||
} 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);
|
||||
});
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
// RSS feed duplication helpers.
|
||||
|
||||
function buildDuplicateRssFeedName(feedName) {
|
||||
const baseName = 'Copy of ' + String(feedName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateRssFeed(rssFeed, duplicateName) {
|
||||
return Object.assign({}, rssFeed, {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
feedUrl: rssFeed.feed_url || '',
|
||||
lastPulledAt: null,
|
||||
pullError: '',
|
||||
pulledItems: [],
|
||||
updateIntervalValue: rssFeed.update_interval_value,
|
||||
updateIntervalUnit: rssFeed.update_interval_unit || 'minutes',
|
||||
itemLimit: rssFeed.item_limit,
|
||||
feedUrlValue: rssFeed.feed_url || ''
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateRssFeedName,
|
||||
buildDuplicateRssFeed
|
||||
};
|
||||
@@ -24,8 +24,9 @@ function buildRssFeedFormViewModel(rssFeed, message, currentUser, options, isEdi
|
||||
inUse: Boolean(viewRssFeed.inUse),
|
||||
pulledItems: viewOptions.pulledItems || viewRssFeed.pulledItems || [],
|
||||
pullError: viewOptions.pullError || viewRssFeed.pullError || '',
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
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"',
|
||||
|
||||
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderRssFeedsPage = require('./list');
|
||||
const renderRssFeedAddPage = require('./add');
|
||||
const renderRssFeedEditPage = require('./edit');
|
||||
const { buildDuplicateRssFeedName, buildDuplicateRssFeed } = require('./duplicate');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
@@ -155,6 +156,29 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/data-sources/rss-feeds/:id/duplicate', requirePermission('rss-feeds.read'), requirePermission('rss-feeds.create'), 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');
|
||||
}
|
||||
|
||||
let duplicateName = buildDuplicateRssFeedName(rssFeed.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_rss_feeds', duplicateName)) {
|
||||
duplicateName = buildDuplicateRssFeedName(rssFeed.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderRssFeedAddPage(buildDuplicateRssFeed(rssFeed, duplicateName), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
messageVariant: 'info',
|
||||
showSaveSecondaryActions: true
|
||||
}));
|
||||
} 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);
|
||||
});
|
||||
|
||||
@@ -3,6 +3,6 @@
|
||||
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));
|
||||
module.exports = function renderTimetableGroupAddPage(timetableGroup, timetableEntries, message, currentUser, options) {
|
||||
return renderView('data-sources/timetables/form', buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, false, options));
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
// Timetable duplication helpers.
|
||||
|
||||
function buildDuplicateTimetableGroupName(groupName) {
|
||||
const baseName = 'Copy of ' + String(groupName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateTimetableGroup(timetableGroup, duplicateName) {
|
||||
return Object.assign({}, timetableGroup, {
|
||||
id: null,
|
||||
name: duplicateName,
|
||||
shortDescription: timetableGroup.short_description || ''
|
||||
});
|
||||
}
|
||||
|
||||
function buildDuplicateTimetableEntry(timetableEntry) {
|
||||
return Object.assign({}, timetableEntry, {
|
||||
id: null,
|
||||
schedule_group_id: null
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateTimetableGroupName,
|
||||
buildDuplicateTimetableGroup,
|
||||
buildDuplicateTimetableEntry
|
||||
};
|
||||
@@ -26,8 +26,9 @@ function formatDateTimeLocalValue(value) {
|
||||
return `${year}-${month}-${day}T${hours}:${minutes}`;
|
||||
}
|
||||
|
||||
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit) {
|
||||
function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, message, currentUser, isEdit, options) {
|
||||
const viewTimetableGroup = Object.assign(buildDefaultTimetableGroup(), timetableGroup || {});
|
||||
const viewOptions = options || {};
|
||||
const viewTimetableEntries = Array.isArray(timetableEntries) && timetableEntries.length
|
||||
? timetableEntries.map(function (entry) {
|
||||
return Object.assign({}, entry, {
|
||||
@@ -42,11 +43,14 @@ function buildTimetableGroupFormViewModel(timetableGroup, timetableEntries, mess
|
||||
active: 'timetables',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
isEdit: Boolean(isEdit),
|
||||
timetableGroup: viewTimetableGroup,
|
||||
timetableEntries: viewTimetableEntries,
|
||||
inUse: Boolean(viewTimetableGroup.inUse),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
||||
deleteConfirmMessage: isEdit ? 'Delete this timetable group?' : '',
|
||||
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"',
|
||||
|
||||
@@ -4,6 +4,7 @@ const { buildPagination } = require('../../../lib/pagination');
|
||||
const renderTimetableGroupsPage = require('./list');
|
||||
const renderTimetableGroupAddPage = require('./add');
|
||||
const renderTimetableGroupEditPage = require('./edit');
|
||||
const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDuplicateTimetableEntry } = require('./duplicate');
|
||||
|
||||
async function getDataSourceUsageMaps(pool, common) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
||||
@@ -25,8 +26,8 @@ async function getDataSourceUsageMaps(pool, common) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'schedule_group_id')) {
|
||||
const timetableGroupId = Number(value.schedule_group_id);
|
||||
if (Object.prototype.hasOwnProperty.call(value, 'timetable_group_id')) {
|
||||
const timetableGroupId = Number(value.timetable_group_id);
|
||||
if (Number.isFinite(timetableGroupId)) {
|
||||
timetableGroupIds.add(timetableGroupId);
|
||||
}
|
||||
@@ -176,6 +177,32 @@ module.exports = function registerTimetableRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
app.get('/data-sources/timetables/:id/duplicate', requirePermission('timetables.read'), requirePermission('timetables.create'), 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 timetableEntries = typeof common.fetchTimetableEntriesByGroupId === 'function'
|
||||
? await common.fetchTimetableEntriesByGroupId(pool, timetableGroup.id)
|
||||
: [];
|
||||
|
||||
let duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'i_schedule_groups', duplicateName)) {
|
||||
duplicateName = buildDuplicateTimetableGroupName(timetableGroup.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(renderTimetableGroupAddPage(buildDuplicateTimetableGroup(timetableGroup, duplicateName), timetableEntries.map(buildDuplicateTimetableEntry), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
messageVariant: 'info'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
app.post('/data-sources/timetables', requirePermission('timetables.create'), async function (req, res, next) {
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
|
||||
@@ -59,6 +59,7 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
verifyPassword: deps.verifyPassword,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
createUserSession: deps.createUserSession,
|
||||
setSessionCookie: deps.setSessionCookie
|
||||
});
|
||||
@@ -70,6 +71,7 @@ function registerAuthAndAccountRoutes(app, deps) {
|
||||
formatDashboardDate: deps.formatDashboardDate,
|
||||
getAuditUserId: deps.getAuditUserId,
|
||||
hashPassword: deps.hashPassword,
|
||||
validatePasswordStrength: deps.validatePasswordStrength,
|
||||
readArrayField: deps.readArrayField,
|
||||
rbacData: deps.rbacData,
|
||||
requirePermission: deps.requirePermission
|
||||
|
||||
@@ -9,6 +9,12 @@ const TASKS_PER_PAGE = 25;
|
||||
const QUEUE_PAGE_URL = '/settings/tasks-background';
|
||||
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
|
||||
|
||||
const DATE_FILTER_FIELDS = {
|
||||
createdAt: 'queued',
|
||||
startedAt: 'started',
|
||||
finishedAt: 'finished'
|
||||
};
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
if (value % 60000 === 0) {
|
||||
@@ -36,6 +42,54 @@ function normalizeSourceFilter(sourceType, sourceId) {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDateInput(value) {
|
||||
const normalizedValue = String(value || '').trim();
|
||||
return /^\d{4}-\d{2}-\d{2}$/.test(normalizedValue) ? normalizedValue : '';
|
||||
}
|
||||
|
||||
function normalizeDateField(value) {
|
||||
const normalizedField = String(value || '').trim();
|
||||
return DATE_FILTER_FIELDS[normalizedField] ? normalizedField : 'createdAt';
|
||||
}
|
||||
|
||||
function getIsoDateString(value) {
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return date.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
function getTaskDateValue(task, dateField) {
|
||||
if (!task || !dateField) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return task[dateField] || '';
|
||||
}
|
||||
|
||||
function taskMatchesDateFilter(task, dateFilter) {
|
||||
if (!dateFilter || (!dateFilter.from && !dateFilter.to)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const taskDate = getIsoDateString(getTaskDateValue(task, dateFilter.field));
|
||||
if (!taskDate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dateFilter.from && taskDate < dateFilter.from) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (dateFilter.to && taskDate > dateFilter.to) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function taskMatchesFilters(task, sourceFilter) {
|
||||
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
||||
return true;
|
||||
@@ -107,12 +161,14 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
queryState.sourceType = sourceFilter.sourceType;
|
||||
queryState.sourceId = sourceFilter.sourceId;
|
||||
const filteredTasks = tasks.filter(function (task) {
|
||||
return taskMatchesFilters(task, sourceFilter) && taskMatchesSearch(queryState.search, [
|
||||
return taskMatchesFilters(task, sourceFilter) && taskMatchesDateFilter(task, { field: 'createdAt', from: '', to: '' }) && taskMatchesSearch(queryState.search, [
|
||||
'title',
|
||||
'key',
|
||||
'category',
|
||||
'status',
|
||||
'errorMessage',
|
||||
'createdAt',
|
||||
'startedAt',
|
||||
'finishedAt',
|
||||
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||
@@ -199,6 +255,11 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
||||
summary: summary,
|
||||
statusCards: statusCards,
|
||||
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||
search: queryState.search,
|
||||
sort: sort,
|
||||
direction: direction,
|
||||
sourceType: queryState.sourceType,
|
||||
sourceId: queryState.sourceId,
|
||||
clearFiltersUrl: QUEUE_PAGE_URL,
|
||||
hasActiveFilters: hasActiveFilters,
|
||||
pagination: pagination,
|
||||
|
||||
@@ -77,6 +77,8 @@ module.exports = function registerFontRoutes(app, deps) {
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
const setAuthMessageCookie = deps.setAuthMessageCookie;
|
||||
|
||||
const FONT_FAMILY_MAX_LENGTH = 255;
|
||||
|
||||
if (!pool || !pages || !upload || !uploadDir) {
|
||||
throw new Error('registerFontRoutes requires pool, pages, upload, and uploadDir.');
|
||||
}
|
||||
@@ -106,7 +108,7 @@ module.exports = function registerFontRoutes(app, deps) {
|
||||
return res.status(400).send('No font file was uploaded.');
|
||||
}
|
||||
|
||||
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim());
|
||||
const result = await fontLibrary.addFontFile(uploadDir, req.file, String(req.body && req.body.font_family || '').trim().slice(0, FONT_FAMILY_MAX_LENGTH));
|
||||
if (backgroundTaskQueue && typeof backgroundTaskQueue.enqueueTask === 'function') {
|
||||
await backgroundTaskQueue.enqueueTask({
|
||||
key: `font-sync:${result && result.font && result.font.id ? result.font.id : Date.now()}`,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// RBAC role duplicate helpers.
|
||||
|
||||
function buildDuplicateRoleName(roleName) {
|
||||
const source = String(roleName || 'Role').trim() || 'Role';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateRole(role, duplicateName) {
|
||||
const source = role || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateRoleName(source.name)).trim(),
|
||||
description: String(source.description || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateRoleName,
|
||||
buildDuplicateRole
|
||||
};
|
||||
@@ -1,5 +1,81 @@
|
||||
// Shared RBAC form view-model helpers.
|
||||
|
||||
function normalizeSectionId(value) {
|
||||
return String(value || '').trim().toLowerCase().replace(/[^a-z0-9]+/g, '-');
|
||||
}
|
||||
|
||||
function buildPermissionSections(permissionGroups) {
|
||||
const sections = [];
|
||||
const sectionIndex = new Map();
|
||||
const actionDefinitions = [
|
||||
{ key: 'read', label: 'Read' },
|
||||
{ key: 'create', label: 'Create' },
|
||||
{ key: 'update', label: 'Update' },
|
||||
{ key: 'delete', label: 'Delete' },
|
||||
{ key: 'allow', label: 'Allow' }
|
||||
];
|
||||
|
||||
(Array.isArray(permissionGroups) ? permissionGroups : []).forEach(function (group) {
|
||||
const sectionTitle = String(group && group.categoryName || 'Permissions').trim() || 'Permissions';
|
||||
const sectionKey = normalizeSectionId(sectionTitle) || 'permissions';
|
||||
|
||||
if (!sectionIndex.has(sectionKey)) {
|
||||
sectionIndex.set(sectionKey, {
|
||||
id: sectionKey,
|
||||
title: sectionTitle,
|
||||
sectionOrder: Number(group && group.sectionOrder) || 999,
|
||||
groups: []
|
||||
});
|
||||
sections.push(sectionIndex.get(sectionKey));
|
||||
}
|
||||
|
||||
const section = sectionIndex.get(sectionKey);
|
||||
const groupPermissions = Array.isArray(group && group.permissions) ? group.permissions.slice() : [];
|
||||
const sectionGroup = Object.assign({}, group, {
|
||||
permissions: groupPermissions
|
||||
});
|
||||
|
||||
section.groups.push(sectionGroup);
|
||||
});
|
||||
|
||||
sections.forEach(function (section) {
|
||||
section.actions = actionDefinitions.map(function (actionDefinition) {
|
||||
return {
|
||||
key: actionDefinition.key,
|
||||
label: actionDefinition.label
|
||||
};
|
||||
});
|
||||
|
||||
section.groups.sort(function (left, right) {
|
||||
const leftOrder = Number(left.resourceOrder) || 999;
|
||||
const rightOrder = Number(right.resourceOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
section.groups.forEach(function (group) {
|
||||
group.cells = section.actions.map(function (action) {
|
||||
return (Array.isArray(group.permissions) ? group.permissions : []).find(function (permission) {
|
||||
return String(permission.actionKey || '').trim().toLowerCase() === action.key;
|
||||
}) || null;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
sections.sort(function (left, right) {
|
||||
const leftOrder = Number(left.sectionOrder) || 999;
|
||||
const rightOrder = Number(right.sectionOrder) || 999;
|
||||
if (leftOrder !== rightOrder) {
|
||||
return leftOrder - rightOrder;
|
||||
}
|
||||
return String(left.title || '').localeCompare(String(right.title || ''));
|
||||
});
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function buildRbacAddViewModel(message, currentUser, formValues, permissionGroups, users, pagination, messageVariant) {
|
||||
return {
|
||||
title: 'Create role',
|
||||
@@ -19,11 +95,12 @@ function buildRbacAddViewModel(message, currentUser, formValues, permissionGroup
|
||||
currentUser: currentUser || null,
|
||||
formValues: formValues || {},
|
||||
permissionGroups: permissionGroups || [],
|
||||
permissionSections: buildPermissionSections(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"'
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-redirect="response-url" data-async-save-new-url="/rbac/new"'
|
||||
};
|
||||
}
|
||||
|
||||
@@ -46,10 +123,12 @@ function buildRbacEditViewModel(role, message, currentUser, permissionGroups, us
|
||||
role: role,
|
||||
inUse: Boolean(role && role.inUse),
|
||||
permissionGroups: permissionGroups || [],
|
||||
permissionSections: buildPermissionSections(permissionGroups),
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
|
||||
scripts: ['js/rbac-permissions.js']
|
||||
scripts: ['js/rbac-permissions.js'],
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/rbac" data-async-save-new-url="/rbac/new"'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// User duplicate helpers.
|
||||
|
||||
function buildDuplicateUserName(name) {
|
||||
const source = String(name || 'User').trim() || 'User';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateUser(user, duplicateName, duplicateUsername) {
|
||||
const source = user || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateUserName(source.name)).trim(),
|
||||
username: String(duplicateUsername || '').trim()
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateUserName,
|
||||
buildDuplicateUser
|
||||
};
|
||||
@@ -32,7 +32,7 @@ function buildUsersAddViewModel(message, currentUser, roles, formValues, message
|
||||
footerDeleteTitle: 'Delete is available after the user has been created.',
|
||||
footerShowDelete: true,
|
||||
message: message,
|
||||
messageVariant: messageVariant || 'primary',
|
||||
messageVariant: messageVariant || 'success',
|
||||
currentUser: currentUser || null,
|
||||
roles: mapRolesForForm(roles, []),
|
||||
formValues: formValues || {},
|
||||
@@ -55,11 +55,13 @@ function buildUsersEditViewModel(user, message, currentUser, roles) {
|
||||
footerDeleteConfirmMessage: 'Delete this user?',
|
||||
footerDeleteTitle: user && user.inUse ? 'Delete is disabled while this user is the only account left.' : '',
|
||||
footerShowDelete: true,
|
||||
showSaveSecondaryActions: true,
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
user: user,
|
||||
inUse: Boolean(user && user.inUse),
|
||||
roles: mapRolesForForm(roles, user && user.roleIds)
|
||||
roles: mapRolesForForm(roles, user && user.roleIds),
|
||||
formAttrs: 'data-async-save data-async-save-close-url="/users" data-async-save-new-url="/users/new"'
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
// Announcement duplication helpers.
|
||||
|
||||
function buildDuplicateAnnouncementName(announcementName) {
|
||||
const baseName = 'Copy of ' + String(announcementName || '').trim();
|
||||
return baseName;
|
||||
}
|
||||
|
||||
function buildDuplicateAnnouncement(announcement, duplicateName) {
|
||||
return Object.assign({}, announcement, {
|
||||
id: null,
|
||||
short_label: duplicateName,
|
||||
expires_at: null,
|
||||
screen_ids: Array.isArray(announcement && announcement.screen_ids) ? announcement.screen_ids.slice() : []
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateAnnouncementName,
|
||||
buildDuplicateAnnouncement
|
||||
};
|
||||
@@ -24,8 +24,11 @@ function buildAnnouncementFormViewModel(announcement, message, currentUser, opti
|
||||
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 viewOptions = options || {};
|
||||
const viewAnnouncement = Object.assign({}, buildDefaultAnnouncement(), announcement || {});
|
||||
const canDelete = Boolean(currentUser && Array.isArray(currentUser.permissionKeys) && currentUser.permissionKeys.indexOf('announcements.delete') !== -1);
|
||||
const hasTargets = Array.isArray(viewAnnouncement.screen_ids) && viewAnnouncement.screen_ids.length > 0;
|
||||
const isActive = !(viewAnnouncement.expires_at && new Date(viewAnnouncement.expires_at).getTime() <= Date.now());
|
||||
|
||||
return {
|
||||
title: isEdit ? 'Edit announcement' : 'Add announcement',
|
||||
@@ -34,9 +37,29 @@ function buildAnnouncementFormViewModel(announcement, message, currentUser, opti
|
||||
currentUser: currentUser || null,
|
||||
announcement: viewAnnouncement,
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showDeleteAction: Boolean(isEdit && canDelete),
|
||||
showSaveSecondaryActions: true,
|
||||
messageVariant: viewOptions.messageVariant || '',
|
||||
actionLabel: isActive ? 'Stop' : 'Play',
|
||||
actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
actionDisabled: !isActive && !hasTargets,
|
||||
actionClassName: !isActive && !hasTargets
|
||||
? 'btn-outline-info'
|
||||
: (isActive ? 'btn-outline-warning' : 'btn-info'),
|
||||
actionDisabledTitle: !isActive && !hasTargets
|
||||
? 'Select at least one screen group to play this announcement.'
|
||||
: '',
|
||||
actionConfirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
actionPath: isEdit && viewAnnouncement.id
|
||||
? (isActive ? '/announcements/' + viewAnnouncement.id + '/stop' : '/announcements/' + viewAnnouncement.id + '/play')
|
||||
: '',
|
||||
showDeleteAction: Boolean(canDelete || !isEdit),
|
||||
deleteDisabled: !isEdit || !Boolean(viewAnnouncement && viewAnnouncement.canDelete),
|
||||
deleteConfirmMessage: isEdit ? 'Delete this announcement?' : '',
|
||||
deleteTitle: !isEdit
|
||||
? 'Delete is available after the announcement has been created.'
|
||||
: (!Boolean(viewAnnouncement && viewAnnouncement.canDelete) ? 'This announcement is currently active and cannot be deleted.' : ''),
|
||||
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',
|
||||
|
||||
@@ -21,10 +21,15 @@ function formatStatusLabel(announcement) {
|
||||
|
||||
function formatActionButton(announcement) {
|
||||
const isActive = formatStatusLabel(announcement) === 'Active';
|
||||
const hasTargets = Math.max(0, Number(announcement && announcement.screen_target_count) || 0) > 0;
|
||||
return {
|
||||
label: isActive ? 'Stop' : 'Play',
|
||||
icon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
className: isActive ? 'btn-outline-warning' : 'btn-outline-success',
|
||||
disabled: !isActive && !hasTargets,
|
||||
className: !isActive && !hasTargets
|
||||
? 'btn-outline-info'
|
||||
: (isActive ? 'btn-outline-warning' : 'btn-info'),
|
||||
disabledTitle: !isActive && !hasTargets ? 'Select at least one screen group to play this announcement.' : '',
|
||||
confirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
@@ -32,6 +37,10 @@ function formatActionButton(announcement) {
|
||||
};
|
||||
}
|
||||
|
||||
function isAnnouncementDeletionBlocked(announcement) {
|
||||
return formatStatusLabel(announcement) === 'Active';
|
||||
}
|
||||
|
||||
function formatIconLabel(iconKey) {
|
||||
const normalized = String(iconKey || '').trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON;
|
||||
return ANNOUNCEMENT_ICON_LABELS[normalized] || normalized;
|
||||
@@ -69,6 +78,7 @@ function formatScreenTargetsLabel(announcement) {
|
||||
module.exports = function renderAnnouncementsPage(data, message, currentUser) {
|
||||
const announcements = (data.announcements || []).map(function (announcement) {
|
||||
const actionButton = formatActionButton(announcement);
|
||||
const deleteDisabled = isAnnouncementDeletionBlocked(announcement);
|
||||
return Object.assign({}, announcement, {
|
||||
shortLabel: String(announcement.short_label || '').trim(),
|
||||
iconKey: String(announcement.icon_key || DEFAULT_ANNOUNCEMENT_ICON).trim().toLowerCase() || DEFAULT_ANNOUNCEMENT_ICON,
|
||||
@@ -77,9 +87,13 @@ module.exports = function renderAnnouncementsPage(data, message, currentUser) {
|
||||
expiresAtLabel: formatExpiresLabel(announcement.expires_at),
|
||||
screenTargetsLabel: formatScreenTargetsLabel(announcement),
|
||||
statusLabel: formatStatusLabel(announcement),
|
||||
deleteDisabled: deleteDisabled,
|
||||
deleteBlockedMessage: deleteDisabled ? 'This announcement is currently active and cannot be deleted.' : '',
|
||||
actionButtonLabel: actionButton.label,
|
||||
actionButtonIcon: actionButton.icon,
|
||||
actionButtonClassName: actionButton.className,
|
||||
actionButtonDisabled: actionButton.disabled,
|
||||
actionButtonDisabledTitle: actionButton.disabledTitle,
|
||||
actionButtonConfirmMessage: actionButton.confirmMessage,
|
||||
actionButtonPath: actionButton.actionPath
|
||||
});
|
||||
|
||||
@@ -11,12 +11,13 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const announcementData = require('#src/data/announcements');
|
||||
const announcementIcons = require('#src/data/announcement-icons');
|
||||
const { buildDuplicateAnnouncementName, buildDuplicateAnnouncement } = require('./duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
const ANNOUNCEMENT_TYPES = [
|
||||
{ value: 'lower-third', label: 'Lower third' },
|
||||
{ value: 'fullscreen', label: 'Fullscreen' },
|
||||
{ value: 'top-banner', label: 'Top banner' }
|
||||
{ value: 'lower-third', label: 'Lower third', icon: 'arrow-bar-down' },
|
||||
{ value: 'fullscreen', label: 'Fullscreen', icon: 'arrows-fullscreen' },
|
||||
{ value: 'top-banner', label: 'Top banner', icon: 'arrow-bar-up' }
|
||||
];
|
||||
const ANNOUNCEMENT_COLORS = [
|
||||
{ value: 'primary', label: 'Primary' },
|
||||
@@ -63,8 +64,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
}
|
||||
|
||||
function isAnnouncementDeletionBlocked(announcement) {
|
||||
const screenIds = Array.isArray(announcement && announcement.screen_ids) ? announcement.screen_ids : [];
|
||||
return isAnnouncementActive(announcement) || screenIds.length > 0;
|
||||
return isAnnouncementActive(announcement);
|
||||
}
|
||||
|
||||
async function fetchAllScreenSlugs() {
|
||||
@@ -221,6 +221,10 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
const durationMode = String(normalized.durationMode || '').trim() || (durationSeconds > 0 ? 'duration' : 'until_disabled');
|
||||
const isActive = isAnnouncementActive(normalized);
|
||||
const canDelete = Boolean(normalized.id) && !isAnnouncementDeletionBlocked(normalized);
|
||||
const hasTargets = Array.isArray(normalized.screen_ids) && normalized.screen_ids.length > 0;
|
||||
const deleteBlockedMessage = canDelete
|
||||
? ''
|
||||
: 'This announcement is currently active and cannot be deleted.';
|
||||
|
||||
return Object.assign({}, normalized, {
|
||||
durationMode: durationMode,
|
||||
@@ -230,7 +234,11 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
isActive: isActive,
|
||||
actionLabel: isActive ? 'Stop' : 'Play',
|
||||
actionIcon: isActive ? 'bi-stop-fill' : 'bi-play-fill',
|
||||
actionClassName: isActive ? 'btn-outline-warning' : 'btn-outline-success',
|
||||
actionDisabled: !isActive && !hasTargets,
|
||||
actionClassName: !isActive && !hasTargets
|
||||
? 'btn-outline-info'
|
||||
: (isActive ? 'btn-outline-warning' : 'btn-info'),
|
||||
actionDisabledTitle: !isActive && !hasTargets ? 'Select at least one screen group to play this announcement.' : '',
|
||||
actionConfirmMessage: isActive
|
||||
? 'Stop this announcement on the selected screens now?'
|
||||
: 'Send this announcement to the selected screens now?',
|
||||
@@ -238,7 +246,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
? `/announcements/${normalized.id}/stop`
|
||||
: `/announcements/${normalized.id}/play`,
|
||||
canDelete: canDelete,
|
||||
deleteBlockedMessage: canDelete ? '' : 'This announcement is still in use by one or more screens.'
|
||||
deleteBlockedMessage: deleteBlockedMessage
|
||||
});
|
||||
}
|
||||
|
||||
@@ -301,6 +309,33 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/announcements/:id/duplicate', requirePermission('announcements.read'), requirePermission('announcements.create'), async function (req, res, next) {
|
||||
try {
|
||||
const announcement = await common.fetchAnnouncementById(pool, Number(req.params.id));
|
||||
if (!announcement) {
|
||||
return res.status(404).send('Announcement not found');
|
||||
}
|
||||
|
||||
const screens = await fetchAnnouncementScreens();
|
||||
let duplicateName = buildDuplicateAnnouncementName(announcement.short_label || announcement.message || 'Announcement');
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'd_announcements', duplicateName, null, 'short_label')) {
|
||||
duplicateName = buildDuplicateAnnouncementName(announcement.short_label || announcement.message || 'Announcement') + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
res.send(pages.renderAnnouncementAddPage(buildAnnouncementFormData(buildDuplicateAnnouncement(announcement, duplicateName)), req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, {
|
||||
announcementTypes: ANNOUNCEMENT_TYPES,
|
||||
announcementColors: ANNOUNCEMENT_COLORS,
|
||||
announcementIcons: ANNOUNCEMENT_ICONS,
|
||||
announcementScreens: buildAnnouncementScreenOptions(screens, announcement.screen_ids || []),
|
||||
messageVariant: 'info'
|
||||
}));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/announcements', requirePermission('announcements.create'), async function (req, res, next) {
|
||||
try {
|
||||
const message = String(req.body.message || '').replace(/\r\n/g, '\n').trim();
|
||||
@@ -421,7 +456,7 @@ module.exports = function registerAnnouncementRoutes(app, deps) {
|
||||
}
|
||||
|
||||
if (isAnnouncementDeletionBlocked(announcement)) {
|
||||
return res.redirect('/announcements/' + announcement.id + '/edit?message=' + encodeURIComponent('This announcement is still in use by one or more screens.'));
|
||||
return res.redirect('/announcements/' + announcement.id + '/edit?message=' + encodeURIComponent('This announcement is currently active and cannot be deleted.'));
|
||||
}
|
||||
|
||||
await pool.query('DELETE FROM d_announcements WHERE id = ?', [announcement.id]);
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// Canvas size duplicate helpers.
|
||||
|
||||
function buildDuplicateCanvasSizeName(name) {
|
||||
const source = String(name || 'Canvas size').trim() || 'Canvas size';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateCanvasSize(canvasSize, duplicateName) {
|
||||
const source = canvasSize || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateCanvasSizeName(source.name)).trim(),
|
||||
width: Math.max(1, Number(source.width || 1920)),
|
||||
height: Math.max(1, Number(source.height || 1080))
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateCanvasSizeName,
|
||||
buildDuplicateCanvasSize
|
||||
};
|
||||
@@ -1,5 +1,7 @@
|
||||
// Shared canvas size form view-model builder.
|
||||
|
||||
const { MAX_CANVAS_SIZE_DIMENSION } = require('#src/data');
|
||||
|
||||
function buildDefaultCanvasSize() {
|
||||
return {
|
||||
id: null,
|
||||
@@ -19,6 +21,8 @@ function buildCanvasSizeFormViewModel(canvasSize, message, currentUser, isEdit)
|
||||
currentUser: currentUser || null,
|
||||
canvasSize: viewCanvasSize,
|
||||
inUse: Boolean(viewCanvasSize && viewCanvasSize.inUse),
|
||||
dimensionsLocked: Boolean(viewCanvasSize && viewCanvasSize.inUse),
|
||||
maxDimension: MAX_CANVAS_SIZE_DIMENSION,
|
||||
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"',
|
||||
|
||||
@@ -2,8 +2,15 @@
|
||||
|
||||
const renderPlaylistEditorPage = require('./form-page');
|
||||
|
||||
module.exports = async function renderPlaylistFormPage(message, currentUser, data, pool) {
|
||||
module.exports = async function renderPlaylistFormPage(playlistOrMessage, messageOrCurrentUser, currentUserOrData, dataOrPool, poolMaybe) {
|
||||
const hasPlaylistArgument = arguments.length >= 5;
|
||||
const playlist = hasPlaylistArgument ? playlistOrMessage : null;
|
||||
const message = hasPlaylistArgument ? messageOrCurrentUser : playlistOrMessage;
|
||||
const currentUser = hasPlaylistArgument ? currentUserOrData : messageOrCurrentUser;
|
||||
const data = hasPlaylistArgument ? dataOrPool : currentUserOrData;
|
||||
const pool = hasPlaylistArgument ? poolMaybe : dataOrPool;
|
||||
const adminData = data || (pool ? await require('#src/data').fetchAdminData(pool) : { slides: [] });
|
||||
return renderPlaylistEditorPage(null, adminData, message, currentUser, { isEdit: false });
|
||||
|
||||
return renderPlaylistEditorPage(playlist, adminData, message, currentUser, { isEdit: false });
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
// Playlist duplicate helpers.
|
||||
|
||||
function buildDuplicatePlaylistName(name) {
|
||||
const source = String(name || 'Playlist').trim() || 'Playlist';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicatePlaylist(playlist, duplicateName) {
|
||||
const source = playlist || {};
|
||||
return {
|
||||
id: source.id || null,
|
||||
name: String(duplicateName || buildDuplicatePlaylistName(source.name)).trim(),
|
||||
fade_between_slides: Boolean(source.fade_between_slides),
|
||||
skip_unavailable_rtmp: Boolean(source.skip_unavailable_rtmp),
|
||||
canvas_id: source.canvas_id || null
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicatePlaylistName,
|
||||
buildDuplicatePlaylist
|
||||
};
|
||||
@@ -7,6 +7,7 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
const fetchScreensByPlaylistId = deps.fetchScreensByPlaylistId;
|
||||
const { buildPagination } = require('../../../lib/pagination');
|
||||
const requirePermission = deps.requirePermission;
|
||||
const { buildDuplicatePlaylistName, buildDuplicatePlaylist } = require('./duplicate');
|
||||
|
||||
const LIST_PAGE_SIZE = 25;
|
||||
|
||||
@@ -51,6 +52,28 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
||||
});
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/duplicate', requirePermission('playlists.read'), requirePermission('playlists.create'), 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 duplicateName = buildDuplicatePlaylistName(playlist.name);
|
||||
let duplicateIndex = 2;
|
||||
while (await common.fetchDuplicateName(pool, 'c_playlists', duplicateName)) {
|
||||
duplicateName = buildDuplicatePlaylistName(playlist.name) + ' (' + duplicateIndex + ')';
|
||||
duplicateIndex += 1;
|
||||
}
|
||||
|
||||
const duplicatePlaylist = buildDuplicatePlaylist(playlist, duplicateName);
|
||||
res.send(await pages.renderPlaylistFormPage(duplicatePlaylist, req.query.message ? String(req.query.message) : 'Review the copied values and save when ready.', req.currentUser, data, pool));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
|
||||
try {
|
||||
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
|
||||
|
||||
@@ -16,8 +16,9 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
|
||||
screen: viewScreen,
|
||||
inUse: Boolean(viewScreen.inUse),
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
|
||||
deleteConfirmMessage: Boolean(isEdit) ? 'Delete this screen?' : '',
|
||||
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"'
|
||||
|
||||
@@ -3,6 +3,11 @@
|
||||
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));
|
||||
module.exports = function renderSlideAddPage(slideOrData, dataOrMessage, messageOrCurrentUser, currentUserMaybe) {
|
||||
const hasSlideArgument = arguments.length >= 4;
|
||||
const slide = hasSlideArgument ? slideOrData : null;
|
||||
const data = hasSlideArgument ? dataOrMessage : slideOrData;
|
||||
const message = hasSlideArgument ? messageOrCurrentUser : dataOrMessage;
|
||||
const currentUser = hasSlideArgument ? currentUserMaybe : messageOrCurrentUser;
|
||||
return renderView('slides/form', buildSlideFormViewModel(data || {}, slide || null, message, currentUser, false));
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
// Slide duplicate helpers.
|
||||
|
||||
function buildDuplicateSlideName(title) {
|
||||
const source = String(title || 'Slide').trim() || 'Slide';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function buildDuplicateSlide(slide, duplicateName) {
|
||||
const source = slide || {};
|
||||
const content = source.content && typeof source.content === 'object'
|
||||
? JSON.parse(JSON.stringify(source.content))
|
||||
: {};
|
||||
|
||||
return {
|
||||
id: null,
|
||||
title: String(duplicateName || buildDuplicateSlideName(source.title)).trim(),
|
||||
template_id: source.template_id || null,
|
||||
content: content
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateSlideName,
|
||||
buildDuplicateSlide
|
||||
};
|
||||
@@ -53,7 +53,7 @@ function buildSlideFormViewModel(data, slide, message, currentUser, isEdit) {
|
||||
existingContent: viewSlide && viewSlide.content ? viewSlide.content : {}
|
||||
},
|
||||
assetVersion: assetVersion,
|
||||
slideEditorScripts: ['vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
|
||||
slideEditorScripts: ['vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion)),
|
||||
currentUser: currentUser || null
|
||||
};
|
||||
}
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
// Template duplicate helpers.
|
||||
|
||||
function buildDuplicateTemplateName(name) {
|
||||
const source = String(name || 'Template').trim() || 'Template';
|
||||
return 'Copy of ' + source;
|
||||
}
|
||||
|
||||
function cloneRegion(region) {
|
||||
const cloned = JSON.parse(JSON.stringify(region || {}));
|
||||
delete cloned.id;
|
||||
delete cloned.template_id;
|
||||
delete cloned.created_at;
|
||||
delete cloned.modified_at;
|
||||
delete cloned.created_by;
|
||||
delete cloned.modified_by;
|
||||
return cloned;
|
||||
}
|
||||
|
||||
function buildDuplicateTemplate(template, duplicateName) {
|
||||
const source = template || {};
|
||||
return {
|
||||
id: null,
|
||||
name: String(duplicateName || buildDuplicateTemplateName(source.name)).trim(),
|
||||
canvas_size_id: source.canvas_size_id || null,
|
||||
canvas_size_width: source.canvas_size_width,
|
||||
canvas_size_height: source.canvas_size_height,
|
||||
background_color: source.background_color,
|
||||
background_image_path: source.background_image_path,
|
||||
region_usage: [],
|
||||
regions: Array.isArray(source.regions) ? source.regions.map(cloneRegion) : []
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
buildDuplicateTemplateName,
|
||||
buildDuplicateTemplate
|
||||
};
|
||||
@@ -55,15 +55,17 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
|
||||
template: current,
|
||||
inUse: Boolean(current.inUse),
|
||||
isEdit: Boolean(isEdit),
|
||||
showSaveSecondaryActions: Boolean(isEdit),
|
||||
showSaveSecondaryActions: true,
|
||||
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"',
|
||||
formAttrs: 'data-template-has-slides="' + (current.slide_count ? 'true' : 'false') + '" data-async-save data-async-save-close-url="/templates"' + (isEdit ? '' : ' data-async-save-new-redirect="response-url"') + ' data-async-save-new-url="/templates/new"',
|
||||
cancelUrl: '/templates',
|
||||
cancelConfirmMessage: "You've made changes. Are you sure you want to leave this page?",
|
||||
deleteUrl: isEdit && current.id ? '/templates/' + current.id + '/delete' : '',
|
||||
deleteConfirmMessage: isEdit ? 'Delete this template?' : '',
|
||||
canvasSizes: canvasSizes || [],
|
||||
animationPresets: animationPresets,
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qrcode-generator/qrcode.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion])
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +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>
|
||||
<form id="account-password-form" method="post" action="/account/password" data-async-save data-async-save-refresh-page="true">
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="account-current-password" class="form-label">Current password</label>
|
||||
@@ -40,17 +40,18 @@
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="account-new-password" class="form-label">New password</label>
|
||||
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
<input id="account-new-password" type="password" name="new_password" class="form-control" autocomplete="new-password" minlength="10" required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="account-confirm-password" class="form-label">Confirm new password</label>
|
||||
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
<input id="account-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" required />
|
||||
</div>
|
||||
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="Password actions">
|
||||
<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>
|
||||
<a class="btn btn-warning" href="{{returnUrl}}" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -8,6 +8,9 @@
|
||||
<div class="alert alert-warning">{{message}}</div>
|
||||
{{/if}}
|
||||
<form method="post" action="/login">
|
||||
{{#if returnTo}}
|
||||
<input type="hidden" name="returnTo" value="{{returnTo}}" />
|
||||
{{/if}}
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input id="username" name="username" type="text" class="form-control" autocomplete="username" required />
|
||||
|
||||
@@ -14,11 +14,11 @@
|
||||
<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 />
|
||||
<input id="api-source-name" name="name" class="form-control" value="{{apiSource.name}}" maxlength="128" data-limit-text-length 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 />
|
||||
<input id="api-source-url" name="api_url" type="url" class="form-control" value="{{apiSource.apiUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/api.json" required />
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
@@ -35,7 +35,7 @@
|
||||
</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" />
|
||||
<input id="api-source-items-path" name="items_path" class="form-control" value="{{apiSource.itemsPath}}" maxlength="255" data-limit-text-length placeholder="items, results.data, or another array path" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="row mt-2">
|
||||
@@ -49,18 +49,18 @@
|
||||
<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" />
|
||||
<input id="api-source-auth-username" name="auth_username" class="form-control" value="{{apiSource.authUsername}}" maxlength="255" data-limit-text-length 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" />
|
||||
<input id="api-source-auth-password" name="auth_password" type="password" class="form-control" value="{{apiSource.authPassword}}" maxlength="255" data-limit-text-length 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>
|
||||
<div class="input-group">
|
||||
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" autocomplete="off" spellcheck="false" autocapitalize="off" autocorrect="off" data-api-source-bearer-token-input />
|
||||
<input id="api-source-auth-bearer-token" name="auth_bearer_token" type="password" class="form-control" value="{{apiSource.authBearerToken}}" maxlength="255" data-limit-text-length autocomplete="off" spellcheck="false" autocapitalize="off" autocorrect="off" data-api-source-bearer-token-input />
|
||||
<button type="button" class="btn btn-outline-secondary" aria-label="Show bearer token" aria-pressed="false" data-api-source-bearer-token-toggle>
|
||||
<i class="bi bi-eye" aria-hidden="true"></i>
|
||||
</button>
|
||||
@@ -70,11 +70,11 @@
|
||||
<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" />
|
||||
<input id="api-source-auth-header-name" name="auth_header_name" class="form-control" value="{{apiSource.authHeaderName}}" maxlength="255" data-limit-text-length 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}}" />
|
||||
<input id="api-source-auth-header-value" name="auth_header_value" class="form-control" value="{{apiSource.authHeaderValue}}" maxlength="255" data-limit-text-length />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -55,11 +55,14 @@
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'api-sources.update' 'api-sources.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'api-sources.create' 'api-sources.update' 'api-sources.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'api-sources.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/data-sources/api-sources/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'api-sources.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/data-sources/api-sources/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'api-sources.delete')}}
|
||||
<form class="inline-form" method="post" action="/data-sources/api-sources/{{id}}/delete" data-confirm-message="Delete this API source?">
|
||||
{{#if inUse}}
|
||||
|
||||
@@ -13,11 +13,11 @@
|
||||
<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 />
|
||||
<input id="rss-feed-name" name="name" class="form-control" value="{{rssFeed.name}}" maxlength="128" data-limit-text-length 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 />
|
||||
<input id="rss-feed-url" name="feed_url" type="url" class="form-control" value="{{rssFeed.feedUrl}}" maxlength="1024" data-limit-text-length placeholder="https://example.com/feed.xml" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-4">
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user