Release v2.5.6
This commit is contained in:
+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">
|
||||
|
||||
@@ -38,11 +38,14 @@
|
||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||
<td data-label="Items pulled">{{itemLabel}}</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'rss-feeds.update' 'rss-feeds.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'rss-feeds.create' 'rss-feeds.update' 'rss-feeds.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'rss-feeds.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/data-sources/rss-feeds/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'rss-feeds.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/data-sources/rss-feeds/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'rss-feeds.delete')}}
|
||||
<form class="inline-form" method="post" action="/data-sources/rss-feeds/{{id}}/delete" data-confirm-message="Delete this RSS feed?">
|
||||
{{#if inUse}}
|
||||
|
||||
@@ -5,80 +5,81 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Timetable group details</h3>
|
||||
</div>
|
||||
<form id="timetable-group-form" method="post" action="{{#if isEdit}}/data-sources/timetables/{{timetableGroup.id}}{{else}}/data-sources/timetables{{/if}}" data-async-save data-async-save-close-url="/data-sources/timetables"{{#unless isEdit}} data-async-save-new-redirect="response-url"{{/unless}} data-async-save-new-url="/data-sources/timetables/new">
|
||||
<form id="timetable-group-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Timetable group details</h3>
|
||||
</div>
|
||||
<div class="card-body d-grid gap-4">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-lg-4">
|
||||
<label for="timetable-group-name" class="form-label">Name</label>
|
||||
<input id="timetable-group-name" name="name" class="form-control" value="{{timetableGroup.name}}" required />
|
||||
<input id="timetable-group-name" name="name" class="form-control" value="{{timetableGroup.name}}" maxlength="128" data-limit-text-length required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-8">
|
||||
<label for="timetable-group-short-description" class="form-label">Short description</label>
|
||||
<input id="timetable-group-short-description" name="short_description" class="form-control" value="{{timetableGroup.shortDescription}}" placeholder="Morning events" />
|
||||
<input id="timetable-group-short-description" name="short_description" class="form-control" value="{{timetableGroup.shortDescription}}" maxlength="255" data-limit-text-length placeholder="Morning events" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div class="d-flex align-items-center justify-content-between gap-3 mb-2">
|
||||
<h4 class="schedule-section-heading mb-0">Entries</h4>
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" data-add-timetable-entry>Add entry</button>
|
||||
</div>
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle schedule-entries-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 18rem;">Title</th>
|
||||
<th>Description</th>
|
||||
<th style="width: 12rem;">Start</th>
|
||||
<th style="width: 12rem;">End</th>
|
||||
<th style="width: 6rem;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-timetable-entries-body>
|
||||
{{#each timetableEntries}}
|
||||
<tr data-timetable-entry-row>
|
||||
<td>
|
||||
<input type="hidden" name="entry_id[]" value="{{id}}" />
|
||||
<input type="text" name="entry_title[]" class="form-control" value="{{title}}" required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="entry_short_description[]" class="form-control" value="{{short_description}}" placeholder="Optional description" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="{{startValue}}" required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<p class="form-text mb-0">Add one or more rows. A timetable region can render upcoming or current entries from this group.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{{saveActionButtons formId="timetable-group-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
{{{saveActionButtons formId="timetable-group-form" saveUrl=formAction cancelUrl=cancelUrl cancelConfirmMessage=cancelConfirmMessage deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary admin-form-card mt-4">
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0 flex-grow-1">Entries</h3>
|
||||
<button type="button" class="btn btn-primary btn-sm ms-auto" data-add-timetable-entry>Add entry</button>
|
||||
</div>
|
||||
<div class="card-body d-grid">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-bordered align-middle schedule-entries-table mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 18rem;">Title</th>
|
||||
<th>Description</th>
|
||||
<th style="width: 12rem;">Start</th>
|
||||
<th style="width: 12rem;">End</th>
|
||||
<th style="width: 6rem;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody data-timetable-entries-body>
|
||||
{{#each timetableEntries}}
|
||||
<tr data-timetable-entry-row>
|
||||
<td>
|
||||
<input type="hidden" name="entry_id[]" value="{{id}}" />
|
||||
<input type="text" name="entry_title[]" class="form-control" value="{{title}}" maxlength="128" data-limit-text-length required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="entry_short_description[]" class="form-control" value="{{short_description}}" maxlength="255" data-limit-text-length placeholder="Optional description" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="{{startValue}}" required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="datetime-local" name="entry_end_datetime[]" class="form-control" value="{{endValue}}" />
|
||||
</td>
|
||||
<td>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm" data-remove-timetable-entry>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<template id="timetable-entry-row-template">
|
||||
<tr data-timetable-entry-row>
|
||||
<td>
|
||||
<input type="hidden" name="entry_id[]" value="" />
|
||||
<input type="text" name="entry_title[]" class="form-control" value="" required />
|
||||
<input type="text" name="entry_title[]" class="form-control" value="" maxlength="128" data-limit-text-length required />
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" name="entry_short_description[]" class="form-control" value="" placeholder="Optional description" />
|
||||
<input type="text" name="entry_short_description[]" class="form-control" value="" maxlength="255" data-limit-text-length placeholder="Optional description" />
|
||||
</td>
|
||||
<td>
|
||||
<input type="datetime-local" name="entry_start_datetime[]" class="form-control" value="" required />
|
||||
|
||||
@@ -44,11 +44,14 @@
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'timetables.update' 'timetables.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'timetables.create' 'timetables.update' 'timetables.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'timetables.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/data-sources/timetables/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'timetables.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/data-sources/timetables/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'timetables.delete')}}
|
||||
<form class="inline-form" method="post" action="/data-sources/timetables/{{id}}/delete" data-confirm-message="Delete this timetable group?">
|
||||
{{#if inUse}}
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
<div class="card-body d-flex flex-column gap-3 pb-0">
|
||||
<div>
|
||||
<label for="font-family" class="form-label">Font family name</label>
|
||||
<input id="font-family" name="font_family" class="form-control" placeholder="Acme Sans" required />
|
||||
<input id="font-family" name="font_family" class="form-control" maxlength="128" data-limit-text-length placeholder="Acme Sans" required />
|
||||
</div>
|
||||
<div>
|
||||
<label for="font-file" class="form-label">Font file</label>
|
||||
|
||||
@@ -17,16 +17,16 @@
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" class="form-label">Role name</label>
|
||||
<input id="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" name="name" class="form-control" value="{{#if isEdit}}{{role.name}}{{else}}{{formValues.name}}{{/if}}" required />
|
||||
<input id="{{#if isEdit}}role-name-edit{{else}}role-name{{/if}}" name="name" class="form-control" value="{{#if isEdit}}{{role.name}}{{else}}{{formValues.name}}{{/if}}" maxlength="64" data-limit-text-length required />
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<label for="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" class="form-label">Description</label>
|
||||
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5">{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
|
||||
<textarea id="{{#if isEdit}}role-description-edit{{else}}role-description{{/if}}" name="description" class="form-control" rows="5" maxlength="512" data-limit-text-length>{{#if isEdit}}{{role.description}}{{else}}{{formValues.description}}{{/if}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end flex-wrap">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showDelete=footerShowDelete}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,38 +84,62 @@
|
||||
<h3 class="card-title">Permissions</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p class="text-body-secondary mb-3">{{#if isEdit}}Each section below is a resource. Pick the actions this role should have for that resource.{{else}}Each section below is a resource. Pick the actions this role should have for that resource.{{/if}}</p>
|
||||
{{#if permissionGroups.length}}
|
||||
<div class="accordion" id="{{#if isEdit}}role-permissions-accordion{{else}}role-create-permissions-accordion{{/if}}">
|
||||
{{#each permissionGroups}}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header" id="{{#if ../isEdit}}heading-{{id}}{{else}}create-heading-{{id}}{{/if}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}">
|
||||
<span>{{title}}</span>
|
||||
{{#if categoryName}}
|
||||
<span class="badge text-bg-secondary ms-2">{{categoryName}}</span>
|
||||
{{/if}}
|
||||
{{#if permissionSections.length}}
|
||||
<div class="card card-outline card-secondary overflow-hidden">
|
||||
<div class="accordion accordion-flush" id="role-permissions-accordion">
|
||||
{{#each permissionSections}}
|
||||
<div class="accordion-item overflow-hidden" data-permission-section data-permission-section-id="{{id}}">
|
||||
<h2 class="accordion-header" id="permission-section-heading-{{id}}">
|
||||
<button class="accordion-button {{#unless @first}}collapsed{{/unless}}" type="button" data-bs-toggle="collapse" data-bs-target="#permission-section-collapse-{{id}}" aria-expanded="{{#if @first}}true{{else}}false{{/if}}" aria-controls="permission-section-collapse-{{id}}">
|
||||
<span class="fw-semibold">{{title}}</span>
|
||||
</button>
|
||||
</h2>
|
||||
<div id="{{#if ../isEdit}}collapse-{{id}}{{else}}create-collapse-{{id}}{{/if}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="{{#if ../isEdit}}heading-{{id}}{{else}}create-heading-{{id}}{{/if}}" data-bs-parent="#{{#if ../isEdit}}role-permissions-accordion{{else}}role-create-permissions-accordion{{/if}}">
|
||||
<div class="accordion-body">
|
||||
<div class="row g-3">
|
||||
{{#each permissions}}
|
||||
<div class="col-12 col-md-6">
|
||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{actionLabel}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
<div id="permission-section-collapse-{{id}}" class="accordion-collapse collapse {{#if @first}}show{{/if}}" aria-labelledby="permission-section-heading-{{id}}" data-bs-parent="#role-permissions-accordion">
|
||||
<div class="accordion-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped align-middle mb-0 rbac-permissions-table" style="table-layout: fixed; width: 100%;">
|
||||
<colgroup>
|
||||
<col />
|
||||
{{#each actions}}
|
||||
<col style="width: 5.5rem;" />
|
||||
{{/each}}
|
||||
</colgroup>
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th scope="col">Type</th>
|
||||
{{#each actions}}
|
||||
<th scope="col" class="text-center">{{label}}</th>
|
||||
{{/each}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each groups}}
|
||||
<tr data-permission-row data-permission-row-id="{{id}}">
|
||||
<th scope="row" class="align-top">
|
||||
<button type="button" class="text-reset border-0 bg-transparent p-0 fw-semibold text-start rbac-row-toggle" style="color: inherit; text-decoration-line: underline; text-decoration-style: dotted; text-underline-offset: 0.2em; cursor: pointer;" data-permission-row-toggle data-permission-row-target="{{id}}">{{title}}</button>
|
||||
</th>
|
||||
{{#each cells}}
|
||||
<td class="text-center">
|
||||
{{#if this}}
|
||||
<label class="d-inline-flex align-items-center justify-content-center w-100 m-0 p-0">
|
||||
<input class="form-check-input m-0" type="checkbox" name="permission_keys[]" value="{{permission_key}}" data-permission-key="{{permission_key}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="visually-hidden">{{actionLabel}} for {{../title}}</span>
|
||||
</label>
|
||||
{{else}}
|
||||
<span class="d-inline-block w-100 text-center text-body-tertiary">—</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
{{/each}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="alert alert-warning mb-0">No permissions are available.</div>
|
||||
|
||||
@@ -57,6 +57,9 @@
|
||||
{{#if (hasPermission ../currentUser 'rbac.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/rbac/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'rbac.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/rbac/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'rbac.delete')}}
|
||||
<form method="post" action="/rbac/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{name}}?">
|
||||
{{#if (gt user_count 0)}}
|
||||
|
||||
@@ -39,8 +39,8 @@
|
||||
</div>
|
||||
{{#if canManage}}
|
||||
{{#if canClearFinished}}
|
||||
<form method="post" action="/settings/tasks-background/clear-finished">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
||||
<form method="post" action="/settings/tasks-background/clear-finished" data-confirm-message="Clear finished background tasks?">
|
||||
<button type="submit" class="btn btn-secondary btn-sm">Clear finished</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -90,21 +90,21 @@
|
||||
</td>
|
||||
<td data-label="Queued">
|
||||
{{#if createdAt}}
|
||||
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
|
||||
<time data-local-datetime data-local-datetime-format="24h" datetime="{{createdAt}}">{{createdAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Started">
|
||||
{{#if startedAt}}
|
||||
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
|
||||
<time data-local-datetime data-local-datetime-format="24h" datetime="{{startedAt}}">{{startedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Finished">
|
||||
{{#if finishedAt}}
|
||||
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
|
||||
<time data-local-datetime data-local-datetime-format="24h" datetime="{{finishedAt}}">{{finishedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
|
||||
@@ -39,12 +39,12 @@
|
||||
<td data-label="Interval">{{intervalLabel}}</td>
|
||||
<td data-label="Next run">
|
||||
{{#if nextRunAt}}
|
||||
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||
<time data-local-datetime data-local-datetime-format="24h" datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last run">
|
||||
{{#if lastRunAt}}
|
||||
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||
<time data-local-datetime data-local-datetime-format="24h" datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last result">
|
||||
|
||||
@@ -16,17 +16,17 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" class="form-label">Username</label>
|
||||
<input id="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" type="text" name="username" class="form-control" autocomplete="username" value="{{#if isEdit}}{{user.username}}{{else}}{{formValues.username}}{{/if}}" {{#if isEdit}}disabled{{else}}required{{/if}} />
|
||||
<input id="{{#if isEdit}}edit-user-username{{else}}user-username{{/if}}" type="text" name="username" class="form-control" autocomplete="username" value="{{#if isEdit}}{{user.username}}{{else}}{{formValues.username}}{{/if}}" maxlength="64" data-limit-text-length required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" class="form-label">Name</label>
|
||||
<input id="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" type="text" name="name" class="form-control" autocomplete="name" value="{{#if isEdit}}{{user.name}}{{else}}{{formValues.name}}{{/if}}" required />
|
||||
<input id="{{#if isEdit}}edit-user-name{{else}}user-name{{/if}}" type="text" name="name" class="form-control" autocomplete="name" value="{{#if isEdit}}{{user.name}}{{else}}{{formValues.name}}{{/if}}" maxlength="128" data-limit-text-length required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="{{#if isEdit}}Profile actions{{else}}User actions{{/if}}">
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=false showSaveAndNew=false showDelete=footerShowDelete}}}
|
||||
{{{saveActionButtons formId=formId saveUrl=footerSaveUrl cancelUrl=footerCancelUrl deleteUrl=footerDeleteUrl deleteConfirmMessage=footerDeleteConfirmMessage deleteDisabled=footerDeleteDisabled deleteTitle=footerDeleteTitle showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions showDelete=footerShowDelete}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -39,13 +39,14 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" class="form-label">Password</label>
|
||||
<input id="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" {{#unless isEdit}}required{{/unless}} />
|
||||
<input id="{{#if isEdit}}edit-user-password{{else}}user-password{{/if}}" type="password" name="password" class="form-control" autocomplete="new-password" minlength="10" {{#unless isEdit}}required{{/unless}} />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" class="form-label">Confirm password</label>
|
||||
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" {{#unless isEdit}}required{{/unless}} />
|
||||
<input id="{{#if isEdit}}edit-user-confirm-password{{else}}user-confirm-password{{/if}}" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="10" {{#unless isEdit}}required{{/unless}} />
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-text mt-2">Use at least 10 characters and include 3 of: uppercase, lowercase, a number, and a symbol.</div>
|
||||
{{#if isEdit}}
|
||||
<div class="form-text mt-2">Leave both fields blank to keep the current password.</div>
|
||||
{{/if}}
|
||||
@@ -78,7 +79,7 @@
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text mt-2">{{#if isEdit}}Select at least one role so the user can sign in with access.{{else}}Select at least one role so the new user can sign in with access.{{/if}}</div>
|
||||
<div class="form-text mt-2">{{#if isEdit}}Select one or more roles. Leave them blank to remove all roles.{{else}}Select one or more roles, or leave them blank to create a user without roles.{{/if}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -67,12 +67,15 @@
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'users.update' 'users.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'users.create' 'users.update' 'users.delete')}}
|
||||
<div class="actions users-row-actions">
|
||||
{{#unless isCurrentUser}}
|
||||
{{#if (hasPermission ../currentUser 'users.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/users/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'users.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/users/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'users.delete')}}
|
||||
<form method="post" action="/users/{{id}}/delete" class="inline-form" data-confirm-message="Delete {{username}}?">
|
||||
<button type="submit" class="btn btn-sm btn-danger">Delete</button>
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
<aside class="app-sidebar bg-body-secondary shadow d-flex flex-column" data-bs-theme="dark">
|
||||
<div class="sidebar-brand">
|
||||
<a href="/dashboard" class="brand-link">
|
||||
<span class="brand-text fw-light">Pulse Signage</span>
|
||||
<span class="brand-text fw-light"><strong>Pulse</strong> Signage</span>
|
||||
</a>
|
||||
</div>
|
||||
<div class="sidebar-wrapper d-flex flex-column flex-grow-1 min-h-0">
|
||||
@@ -196,7 +196,7 @@
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'screens')}}active{{/if}}" href="/screens">
|
||||
<i class="nav-icon bi bi-display"></i>
|
||||
<p>Screens</p>
|
||||
<p>Screen Groups</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
@@ -243,14 +243,6 @@
|
||||
{{#if (anyPermission currentUser 'rss-feeds.read' 'schedules.read' 'api-sources.read')}}
|
||||
<li class="nav-header">DATA SOURCES</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'rss-feeds.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'rss-feeds')}}active{{/if}}" href="/data-sources/rss-feeds">
|
||||
<i class="nav-icon bi bi-rss"></i>
|
||||
<p>RSS feeds</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'api-sources.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'api-sources')}}active{{/if}}" href="/data-sources/api-sources">
|
||||
@@ -259,6 +251,14 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'rss-feeds.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'rss-feeds')}}active{{/if}}" href="/data-sources/rss-feeds">
|
||||
<i class="nav-icon bi bi-rss"></i>
|
||||
<p>RSS feeds</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'timetables.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'timetables')}}active{{/if}}" href="/data-sources/timetables">
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{#if isEdit}}Edit announcement{{#if announcement.short_label}} - {{announcement.short_label}}{{/if}}{{else}}Add announcement{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Update the message, screens, color, or expiry behavior.{{else}}Give the announcement a short description, then define the full message and target screens.{{/if}}</p>
|
||||
<p>{{#if isEdit}}Update the message, screen groups, color, or expiry behavior.{{else}}Give the announcement a short description, then define the full message and target screen groups.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -13,41 +13,43 @@
|
||||
<h3 class="card-title">Content</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-4">
|
||||
<label for="announcement-short-label" class="form-label">{{#if isEdit}}Descriptor{{else}}Short Description{{/if}}</label>
|
||||
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="255" placeholder="{{#if isEdit}}Power warning{{else}}Announcement Short Name{{/if}}" />
|
||||
<div class="mb-3">
|
||||
<label for="announcement-short-label" class="form-label">Short Description</label>
|
||||
<input id="announcement-short-label" name="short_label" class="form-control" value="{{announcement.short_label}}" maxlength="128" data-limit-text-length placeholder="{{#if isEdit}}Power warning{{else}}Announcement Short Name{{/if}}" />
|
||||
<div class="form-text">A short name for admin lists and page titles.</div>
|
||||
</div>
|
||||
<div class="mb-0">
|
||||
<div class="mb-3">
|
||||
<label for="announcement-message" class="form-label">Message</label>
|
||||
<textarea id="announcement-message" name="message" class="form-control" rows="6" required placeholder="{{#if isEdit}}{{else}}Maintenance starts in 10 minutes.{{/if}}">{{announcement.message}}</textarea>
|
||||
<div class="form-text">Use the full text that should appear on screens.</div>
|
||||
</div>
|
||||
<div class="row g-3 mb-0">
|
||||
<div class="col-12 col-md-6 col-xl-12">
|
||||
<label for="announcement-type" class="form-label">Type</label>
|
||||
<select id="announcement-type" name="announcement_type" class="form-select">
|
||||
{{#each announcementTypes}}
|
||||
<option value="{{value}}" {{#if (eq ../announcement.announcement_type value)}}selected{{/if}}>{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-12">
|
||||
<label for="announcement-color" class="form-label">Color</label>
|
||||
<div class="row g-2 align-items-stretch">
|
||||
<div class="col">
|
||||
<select id="announcement-color" name="color_key" class="form-select">
|
||||
{{#each announcementColors}}
|
||||
<option value="{{value}}" {{#if (eq ../announcement.color_key value)}}selected{{/if}}>{{label}}</option>
|
||||
<div class="col-12 col-lg-6">
|
||||
<label id="announcement-type-label" class="form-label">Type</label>
|
||||
<div class="announcement-type-picker" data-announcement-type-picker-shell>
|
||||
<input id="announcement-type" name="announcement_type" type="hidden" value="{{announcement.announcement_type}}" data-announcement-type-value />
|
||||
<button type="button" class="announcement-type-picker__toggle" data-announcement-type-picker-toggle aria-expanded="false" aria-controls="announcement-type-picker-menu" title="Select a type">
|
||||
<span class="announcement-type-picker__toggle-icon" data-announcement-type-picker-icon aria-hidden="true"></span>
|
||||
<span class="announcement-type-picker__toggle-content">
|
||||
<span class="announcement-type-picker__toggle-label" data-announcement-type-picker-label>Select a type</span>
|
||||
<span class="announcement-type-picker__toggle-hint">Click to choose</span>
|
||||
</span>
|
||||
<i class="bi bi-chevron-down announcement-type-picker__caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div id="announcement-type-picker-menu" class="announcement-type-picker__menu" data-announcement-type-picker hidden>
|
||||
<div class="announcement-type-picker__grid" role="radiogroup" aria-labelledby="announcement-type-label">
|
||||
{{#each announcementTypes}}
|
||||
<button type="button" class="announcement-type-picker__option {{#if (eq ../announcement.announcement_type value)}}is-selected{{/if}}" data-announcement-type-option data-type-key="{{value}}" data-type-label="{{label}}" data-type-icon="{{icon}}" aria-pressed="{{#if (eq ../announcement.announcement_type value)}}true{{else}}false{{/if}}" title="{{label}}">
|
||||
<span class="announcement-type-picker__option-icon" aria-hidden="true">
|
||||
<i class="bi bi-{{icon}}"></i>
|
||||
</span>
|
||||
<span class="announcement-type-picker__label">{{label}}</span>
|
||||
</button>
|
||||
{{/each}}
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<div class="announcement-color-preview" data-announcement-color-preview aria-hidden="true"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-6 col-xl-12">
|
||||
<div class="col-12 col-lg-6">
|
||||
<div class="form-label">Icon</div>
|
||||
<div class="announcement-icon-picker" data-announcement-icon-picker-shell>
|
||||
<select id="announcement-icon" name="icon_key" class="d-none" tabindex="-1" aria-hidden="true">
|
||||
@@ -55,9 +57,12 @@
|
||||
<option value="{{value}}" {{#if (eq ../announcement.icon_key value)}}selected{{/if}}>{{label}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<button type="button" class="announcement-icon-picker__toggle" data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu">
|
||||
<button type="button" class="announcement-icon-picker__toggle" data-announcement-icon-picker-toggle aria-expanded="false" aria-controls="announcement-icon-picker-menu" title="Select an icon">
|
||||
<span class="announcement-icon-picker__toggle-icon" data-announcement-icon-picker-icon aria-hidden="true"></span>
|
||||
<span class="announcement-icon-picker__toggle-label" data-announcement-icon-picker-label>Select an icon</span>
|
||||
<span class="announcement-icon-picker__toggle-content">
|
||||
<span class="announcement-icon-picker__toggle-label" data-announcement-icon-picker-label>Select an icon</span>
|
||||
<span class="announcement-icon-picker__toggle-hint">Click to choose</span>
|
||||
</span>
|
||||
<i class="bi bi-chevron-down announcement-icon-picker__caret" aria-hidden="true"></i>
|
||||
</button>
|
||||
<div id="announcement-icon-picker-menu" class="announcement-icon-picker__menu" data-announcement-icon-picker hidden>
|
||||
@@ -76,6 +81,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label id="announcement-color-label" class="form-label">Color</label>
|
||||
<div class="announcement-color-picker" data-announcement-color-picker-shell>
|
||||
<input id="announcement-color" name="color_key" type="hidden" value="{{announcement.color_key}}" data-announcement-color-value />
|
||||
<div class="announcement-color-picker__grid" role="radiogroup" aria-labelledby="announcement-color-label">
|
||||
{{#each announcementColors}}
|
||||
<button type="button" class="announcement-color-picker__option {{#if (eq ../announcement.color_key value)}}is-selected{{/if}}" data-announcement-color-option data-color-key="{{value}}" data-color-label="{{label}}" aria-pressed="{{#if (eq ../announcement.color_key value)}}true{{else}}false{{/if}}" title="{{label}}">
|
||||
<span class="announcement-color-picker__swatch {{#if (eq value 'light')}}text-bg-light text-dark{{else}}text-bg-{{value}}{{/if}}" aria-hidden="true">
|
||||
<i class="bi bi-check-lg announcement-color-picker__check" aria-hidden="true"></i>
|
||||
</span>
|
||||
<span class="announcement-color-picker__label">{{label}}</span>
|
||||
</button>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,16 +105,16 @@
|
||||
<div class="card card-outline card-primary admin-form-card mb-3">
|
||||
<div class="card-footer d-flex flex-wrap align-items-center">
|
||||
{{#if isEdit}}
|
||||
<button type="submit" class="btn {{announcement.actionClassName}}" form="{{actionFormId}}"><i class="bi {{announcement.actionIcon}} me-1" aria-hidden="true"></i>{{announcement.actionLabel}}</button>
|
||||
<button type="submit" class="btn {{actionClassName}}" form="{{actionFormId}}" {{#if actionDisabled}}disabled title="{{actionDisabledTitle}}"{{/if}}><i class="bi {{actionIcon}} me-1" aria-hidden="true"></i>{{actionLabel}}</button>
|
||||
{{/if}}
|
||||
<div class="ms-auto d-flex flex-wrap align-items-center gap-2">
|
||||
{{{saveActionButtons formId="announcement-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled deleteTitle=announcement.deleteBlockedMessage showDelete=showDeleteAction showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
{{{saveActionButtons formId="announcement-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled deleteTitle=deleteTitle showDelete=showDeleteAction showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card card-outline card-secondary admin-form-card mb-4">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Screens</h3>
|
||||
<h3 class="card-title">Screen Groups</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3 mb-0">
|
||||
@@ -119,14 +140,27 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-12 col-md-4 col-xl-12">
|
||||
<label class="form-label" id="announcement-screens-label">Target screen groups</label>
|
||||
<div class="announcement-screen-actions">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary announcement-screen-action" data-announcement-screen-select-all>Select all</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary announcement-screen-action" data-announcement-screen-select-none>Select none</button>
|
||||
</div>
|
||||
<div class="announcement-screen-picker" role="group" aria-labelledby="announcement-screens-label">
|
||||
{{#each announcementScreens}}
|
||||
<div class="announcement-screen-option">
|
||||
<input class="btn-check" type="checkbox" id="announcement-screen-{{id}}" name="screen_ids[]" value="{{id}}" autocomplete="off" {{#if isSelected}}checked{{/if}} />
|
||||
<label class="announcement-screen-chip" for="announcement-screen-{{id}}" title="{{name}}">
|
||||
<span class="announcement-screen-chip__text">{{name}}</span>
|
||||
<span class="announcement-screen-chip__check" aria-hidden="true">
|
||||
<i class="bi bi-check-lg announcement-screen-chip__check-icon"></i>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<label for="announcement-screens" class="form-label">Target screens</label>
|
||||
<select id="announcement-screens" name="screen_ids[]" class="form-select" multiple size="8" required>
|
||||
{{#each announcementScreens}}
|
||||
<option value="{{id}}" {{#if isSelected}}selected{{/if}}>{{name}}{{#if slug}} ({{slug}}){{/if}}</option>
|
||||
{{/each}}
|
||||
</select>
|
||||
<div class="form-text">Select one or more screens for this announcement.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -136,5 +170,4 @@
|
||||
|
||||
{{#if isEdit}}
|
||||
<form id="announcement-action-form" method="post" action="{{announcement.actionPath}}" class="d-none" data-confirm-message="{{announcement.actionConfirmMessage}}" data-async-command></form>
|
||||
<form id="delete-announcement-form" method="post" action="/announcements/{{announcement.id}}/delete" class="d-none" data-confirm-message="Delete this announcement?"></form>
|
||||
{{/if}}
|
||||
@@ -23,7 +23,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="description">Description</th>
|
||||
<th>Screens</th>
|
||||
<th>Screen Groups</th>
|
||||
<th data-table-sort-key="type">Type</th>
|
||||
<th data-table-sort-key="color">Color</th>
|
||||
<th data-table-sort-key="status">Status</th>
|
||||
@@ -47,7 +47,7 @@
|
||||
</div>
|
||||
{{!-- <div class="text-secondary small text-break">{{#if message}}{{truncateText message 50}}{{else}}No message provided.{{/if}}</div> --}}
|
||||
</td>
|
||||
<td data-label="Screens" class="text-break">
|
||||
<td data-label="Screen Groups" class="text-break">
|
||||
<span class="small text-secondary">{{screenTargetsLabel}}</span>
|
||||
</td>
|
||||
<td data-label="Type">{{announcement_type}}</td>
|
||||
@@ -66,17 +66,20 @@
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'announcements.update' 'announcements.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'announcements.create' 'announcements.update' 'announcements.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'announcements.update')}}
|
||||
<form class="inline-form" method="post" action="{{actionButtonPath}}" data-confirm-message="{{actionButtonConfirmMessage}}" data-async-command>
|
||||
<button class="btn btn-sm {{actionButtonClassName}}" type="submit"><i class="bi {{actionButtonIcon}} me-1" aria-hidden="true"></i>{{actionButtonLabel}}</button>
|
||||
<button class="btn btn-sm {{actionButtonClassName}}" type="submit" {{#if actionButtonDisabled}}disabled title="{{actionButtonDisabledTitle}}"{{/if}}><i class="bi {{actionButtonIcon}} me-1" aria-hidden="true"></i>{{actionButtonLabel}}</button>
|
||||
</form>
|
||||
<a class="btn btn-sm btn-primary" href="/announcements/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'announcements.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/announcements/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'announcements.delete')}}
|
||||
<form class="inline-form" method="post" action="/announcements/{{id}}/delete" data-confirm-message="Delete this announcement?">
|
||||
<button class="btn btn-sm btn-danger" type="submit">Delete</button>
|
||||
<button class="btn btn-sm {{#if deleteDisabled}}btn-outline-danger{{else}}btn-danger{{/if}}" type="submit" {{#if deleteDisabled}}disabled title="{{deleteBlockedMessage}}"{{/if}}>Delete</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
@@ -13,18 +13,21 @@
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<label for="canvas-size-name" class="form-label">Name</label>
|
||||
<input id="canvas-size-name" name="name" class="form-control" value="{{canvasSize.name}}" required />
|
||||
<input id="canvas-size-name" name="name" type="text" maxlength="255" data-limit-text-length class="form-control" value="{{canvasSize.name}}" required />
|
||||
</div>
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="canvas-size-width" class="form-label">Width</label>
|
||||
<input id="canvas-size-width" name="width" type="number" min="1" class="form-control" value="{{canvasSize.width}}" required />
|
||||
<input id="canvas-size-width" name="width" type="number" min="1" max="{{maxDimension}}" data-limit-input-value class="form-control" value="{{canvasSize.width}}" {{#if dimensionsLocked}}disabled{{/if}} required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="canvas-size-height" class="form-label">Height</label>
|
||||
<input id="canvas-size-height" name="height" type="number" min="1" class="form-control" value="{{canvasSize.height}}" required />
|
||||
<input id="canvas-size-height" name="height" type="number" min="1" max="{{maxDimension}}" data-limit-input-value class="form-control" value="{{canvasSize.height}}" {{#if dimensionsLocked}}disabled{{/if}} required />
|
||||
</div>
|
||||
</div>
|
||||
{{#if dimensionsLocked}}
|
||||
<div class="form-text mt-2">Dimensions are locked while this canvas size is used by templates.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{{saveActionButtons formId="canvas-size-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled}}}
|
||||
|
||||
@@ -36,11 +36,14 @@
|
||||
<td data-label="Dimensions">{{width}}x{{height}}</td>
|
||||
<td data-label="Templates"><span class="chip">{{templateCount}} template{{#if (gt templateCount 1)}}s{{else}}{{/if}}{{#if (lt templateCount 1)}}s{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'canvas-sizes.update' 'canvas-sizes.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'canvas-sizes.create' 'canvas-sizes.update' 'canvas-sizes.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'canvas-sizes.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/canvas-sizes/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'canvas-sizes.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/canvas-sizes/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'canvas-sizes.delete')}}
|
||||
<form class="inline-form" method="post" action="/canvas-sizes/{{id}}/delete" data-confirm-message="Delete this canvas size?">
|
||||
{{#if (gt templateCount 0)}}
|
||||
|
||||
@@ -17,13 +17,13 @@
|
||||
<div class="screen-command-panel">
|
||||
<div class="screen-command-panel-left">
|
||||
<div class="screen-command-selector-field">
|
||||
<label class="screen-command-selector-label" for="screen-command-select">Target screen</label>
|
||||
<label class="screen-command-selector-label" for="screen-command-select">Target screen group</label>
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-broadcast"></i></span>
|
||||
<select
|
||||
id="screen-command-select"
|
||||
class="form-select"
|
||||
aria-label="Target screen"
|
||||
aria-label="Target screen group"
|
||||
data-screen-command-select
|
||||
>
|
||||
{{#each screens}}
|
||||
@@ -57,13 +57,13 @@
|
||||
<div class="screen-command-panel-right">
|
||||
<div class="screen-command-summary">
|
||||
<span class="screen-command-pill is-live" data-screen-command-pill>Choose a screen</span>
|
||||
<strong data-screen-command-name>Select a target screen</strong>
|
||||
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen.</span>
|
||||
<strong data-screen-command-name>Select a target screen group</strong>
|
||||
<span class="screen-command-summary-meta" data-screen-command-meta>Commands update automatically for the selected screen group.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="empty">No screens are available to target yet.</div>
|
||||
<div class="empty">No screen groups are available to target yet.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -84,7 +84,7 @@
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="client">Client</th>
|
||||
<th data-table-sort-key="screen">Current Screen</th>
|
||||
<th data-table-sort-key="screen">Current Screen Group</th>
|
||||
<th data-table-sort-key="slide">Current Slide</th>
|
||||
<th data-table-sort-key="ip">IP</th>
|
||||
<th data-table-sort-key="viewport">Viewport</th>
|
||||
@@ -99,7 +99,7 @@
|
||||
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
|
||||
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
|
||||
</td>
|
||||
<td data-label="Current Screen">
|
||||
<td data-label="Current Screen Group">
|
||||
<div>{{#if screen_name}}{{screen_name}}{{else}}Unknown{{/if}}</div>
|
||||
</td>
|
||||
<td data-label="Current Slide">
|
||||
@@ -135,7 +135,7 @@
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
<button type="submit" class="btn btn-sm btn-danger" aria-label="Reload client" title="Reload client"><i class="bi bi-arrow-repeat" aria-hidden="true"></i></button>
|
||||
</form>
|
||||
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen" title="Move client to another screen"><i class="bi bi-display" aria-hidden="true"></i></button>
|
||||
<button type="button" class="btn btn-sm btn-danger" data-action="move-screen" data-bs-toggle="modal" data-bs-target="#client-move-screen-modal" aria-label="Move client to another screen group" title="Move client to another screen group"><i class="bi bi-display" aria-hidden="true"></i></button>
|
||||
<form method="post" action="/clients/{{screen_slug}}/commands" class="inline-form" data-async-command>
|
||||
<input type="hidden" name="command" value="previous" />
|
||||
<input type="hidden" name="connectionId" value="{{id}}" />
|
||||
@@ -174,7 +174,7 @@
|
||||
<form id="client-move-screen-form" method="post" action="#" class="d-flex flex-column">
|
||||
<div class="modal-header">
|
||||
<div>
|
||||
<h2 class="modal-title fs-5" id="client-move-screen-modal-label">Move client to another screen</h2>
|
||||
<h2 class="modal-title fs-5" id="client-move-screen-modal-label">Move client to another screen group</h2>
|
||||
</div>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
@@ -183,15 +183,15 @@
|
||||
<input type="hidden" name="connectionId" value="" data-client-move-connection-id />
|
||||
<input type="hidden" name="deviceId" value="" data-client-move-device-id />
|
||||
<input type="hidden" name="clientName" value="" data-client-move-client-name />
|
||||
<label class="form-label" for="client-move-screen-target">Target screen</label>
|
||||
<label class="form-label" for="client-move-screen-target">Target screen group</label>
|
||||
<select class="form-select" id="client-move-screen-target" name="targetScreenSlug" data-client-move-target required>
|
||||
{{#if screens.length}}
|
||||
<option value="">Select a screen</option>
|
||||
<option value="">Select a screen group</option>
|
||||
{{#each screens}}
|
||||
<option value="{{slug}}">{{name}}</option>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<option value="">No screens available</option>
|
||||
<option value="">No screen groups available</option>
|
||||
{{/if}}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Dashboard</h2>
|
||||
<p>Monitor screens and quick actions from one place.</p>
|
||||
<p>Monitor screen groups and quick actions from one place.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,12 +10,12 @@
|
||||
<div>
|
||||
<span class="dashboard-hero-kicker">Live overview</span>
|
||||
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
|
||||
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen snapshot when you want a quick read on playlist assignment and live connections.</p>
|
||||
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen group snapshot when you want a quick read on playlist assignment and live connections.</p>
|
||||
{{#if primaryPlayerUrl}}
|
||||
<div class="alert alert-info mb-0 mt-3 py-2">
|
||||
<strong>Begin onboarding:</strong>
|
||||
open <a href="{{primaryPlayerUrl}}" target="_blank" rel="noreferrer">{{primaryPlayerUrl}}</a>
|
||||
on the public screen to get started.
|
||||
on the public display to get started.
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -29,7 +29,7 @@
|
||||
{{#if (hasPermission currentUser "screens.read")}}
|
||||
<div class="dashboard-hero-stat">
|
||||
<span class="dashboard-hero-stat-value">{{screens.length}}</span>
|
||||
<span class="dashboard-hero-stat-label">screens</span>
|
||||
<span class="dashboard-hero-stat-label">screen groups</span>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser "clients.read")}}
|
||||
@@ -117,11 +117,11 @@
|
||||
<div class="card card-outline card-secondary dashboard-screen-card-shell">
|
||||
<div class="card-header dashboard-screen-card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Screen snapshot</h3>
|
||||
<h3 class="card-title">Screen group snapshot</h3>
|
||||
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
|
||||
and live connection count without the spreadsheet feel.</p>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/screens">Manage screens</a>
|
||||
<a class="btn btn-sm btn-primary" href="/screens">Manage screen groups</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="dashboard-screens-grid" class="dashboard-screen-grid">
|
||||
@@ -158,7 +158,7 @@
|
||||
</article>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="empty dashboard-screen-empty">No screens yet.</div>
|
||||
<div class="empty dashboard-screen-empty">No screen groups yet.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12 col-lg-7">
|
||||
<label for="playlist-name" class="form-label">Name</label>
|
||||
<input id="playlist-name" name="name" class="form-control" value="{{playlist.name}}" placeholder="Lobby loop" required />
|
||||
<input id="playlist-name" name="name" class="form-control" value="{{playlist.name}}" maxlength="128" data-limit-text-length placeholder="Lobby loop" required />
|
||||
</div>
|
||||
<div class="col-12 col-lg-5">
|
||||
<label for="playlist-canvas-size" class="form-label">Canvas size</label>
|
||||
@@ -66,7 +66,7 @@
|
||||
</div>
|
||||
<div class="card-body p-0 playlist-items-table-responsive">
|
||||
<table class="table table-striped w-100 mb-0">
|
||||
<thead><tr><th>Order</th><th>Slide</th><th>Schedule</th><th>Config</th><th>Actions</th></tr></thead>
|
||||
<thead><tr><th>Order</th><th>Slide</th><th>Schedule</th><th>Playback</th><th>Actions</th></tr></thead>
|
||||
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}" data-playlist-canvas-id="{{playlistCanvasId}}">
|
||||
{{#if playlistSlides.length}}
|
||||
{{#each playlistSlides}}
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
<tr>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="slides">Slides</th>
|
||||
<th data-table-sort-key="screens">Screens</th>
|
||||
<th data-table-sort-key="screens">Screen Groups</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@@ -34,13 +34,16 @@
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Slides"><span class="chip">{{slideCount}} slide{{#if (gt slideCount 1)}}s{{else}}{{/if}}{{#if (lt slideCount 1)}}s{{/if}}</span></td>
|
||||
<td data-label="Screens"><span class="chip">{{screenCount}} screen{{#if (gt screenCount 1)}}s{{else}}{{/if}}{{#if (lt screenCount 1)}}s{{/if}}</span></td>
|
||||
<td data-label="Screen Groups"><span class="chip">{{screenCount}} screen group{{#if (eq screenCount 1)}}{{else}}s{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'playlists.update' 'playlists.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'playlists.create' 'playlists.update' 'playlists.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'playlists.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/playlists/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'playlists.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/playlists/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'playlists.delete')}}
|
||||
<form class="inline-form" method="post" action="/playlists/{{id}}/delete" data-confirm-message="Delete this playlist?">
|
||||
{{#if (gt screenCount 0)}}
|
||||
|
||||
@@ -6,9 +6,8 @@
|
||||
</div>
|
||||
<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 {{ruleIndex}}" title="Collapse rule {{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>
|
||||
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-expanded="{{#if isCollapsed}}false{{else}}true{{/if}}" aria-label="Collapse rule {{ruleIndex}}" title="Collapse rule {{ruleIndex}}">
|
||||
<i class="bi {{#if isCollapsed}}bi-plus-lg{{else}}bi-dash-lg{{/if}}" aria-hidden="true" data-schedule-rule-collapse-icon></i>
|
||||
<span class="visually-hidden">Collapse rule {{ruleIndex}}</span>
|
||||
</button>
|
||||
</div>
|
||||
@@ -36,11 +35,14 @@
|
||||
<div class="col-12">
|
||||
<div class="btn-group btn-group-sm w-100 schedule-day-grid" role="group" aria-label="Schedule days">
|
||||
{{#each dayOptions}}
|
||||
<input class="btn-check" type="checkbox" id="{{inputId}}" value="{{value}}" autocomplete="off" {{#if checked}}checked{{/if}} data-schedule-rule-day />
|
||||
<label class="btn btn-outline-primary flex-fill schedule-day-button" for="{{inputId}}">{{label}}</label>
|
||||
<span class="schedule-day-option">
|
||||
<input class="btn-check" type="checkbox" id="{{inputId}}" value="{{value}}" autocomplete="off" {{#if checked}}checked{{/if}} data-schedule-rule-day />
|
||||
<label class="btn btn-outline-primary flex-fill schedule-day-button" for="{{inputId}}">{{label}}</label>
|
||||
</span>
|
||||
{{/each}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="invalid-feedback d-block mt-2" data-schedule-rule-feedback aria-live="polite" hidden></div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,15 +1,11 @@
|
||||
<tr data-playlist-slide-row data-row-key="{{slide.row_key}}" data-slide-id="{{slide.slide_id}}" data-canvas-signature="{{slide.canvasSignature}}" data-media-type="{{slide.media_type}}" data-media-path="{{slide.media_path}}" data-video-source-path="{{slide.videoSourcePath}}" data-video-duration-seconds="{{slide.videoDurationSeconds}}" data-use-video-duration-state="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" data-disable-audio-state="{{#if slide.disableAudio}}true{{else}}false{{/if}}">
|
||||
<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">{{slide.displayOrder}}</span>
|
||||
</div>
|
||||
</td>
|
||||
@@ -33,16 +29,19 @@
|
||||
<div class="playlist-schedule-summary"><span class="chip" data-schedule-summary-chip>{{slide.scheduleSummary}}</span></div>
|
||||
<div class="d-none" data-schedule-rules-container data-schedule-rules='{{json slide.scheduleRules}}'></div>
|
||||
</td>
|
||||
<td data-label="Config">
|
||||
<td data-label="Playback">
|
||||
<div class="playlist-duration-field">
|
||||
<input name="duration_seconds[]" type="text" inputmode="decimal" value="{{slide.durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if slide.useVideoDuration}}disabled{{/if}} data-playlist-duration-input />
|
||||
<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="{{slide.durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if slide.useVideoDuration}}disabled{{/if}} data-limit-input-value data-playlist-duration-input />
|
||||
</div>
|
||||
<input type="hidden" name="use_video_duration[]" value="{{#if slide.useVideoDuration}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-use-video-duration-input />
|
||||
<input type="hidden" name="disable_audio[]" value="{{#if slide.disableAudio}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-disable-audio-input />
|
||||
{{#if slide.useVideoDuration}}
|
||||
<input type="hidden" name="duration_seconds[]" value="{{slide.durationSeconds}}" form="playlist-edit-form" data-video-duration-mirror data-playlist-duration-mirror />
|
||||
{{/if}}
|
||||
<button type="button" class="btn btn-outline-info btn-sm playlist-use-video-duration{{#if slide.useVideoDuration}} active{{/if}}" data-use-video-duration-button aria-pressed="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" aria-label="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" title="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" {{#unless slide.showVideoDurationButton}}hidden{{/unless}}><i class="bi {{#if slide.useVideoDuration}}bi-camera-video{{else}}bi-camera-video-off{{/if}}" aria-hidden="true" data-use-video-duration-icon></i><span class="visually-hidden">{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}</span></button>
|
||||
<button type="button" class="btn {{#if slide.disableAudio}}btn-outline-secondary{{else}}btn-secondary active{{/if}} btn-sm playlist-disable-audio" data-disable-audio-button aria-pressed="{{#if slide.disableAudio}}true{{else}}false{{/if}}" aria-label="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" title="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" {{#unless slide.showMuteButton}}hidden{{/unless}}><i class="bi {{#if slide.disableAudio}}bi-volume-mute{{else}}bi-volume-up{{/if}}" aria-hidden="true" data-disable-audio-icon></i><span class="visually-hidden">{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}</span></button>
|
||||
<button type="button" class="btn {{#if slide.useVideoDuration}}btn-info{{else}}btn-outline-info{{/if}} btn-sm playlist-use-video-duration" data-use-video-duration-button aria-pressed="{{#if slide.useVideoDuration}}true{{else}}false{{/if}}" aria-label="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" title="{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}" {{#unless slide.showVideoDurationButton}}hidden{{/unless}}><i class="bi {{#if slide.useVideoDuration}}bi-camera-video{{else}}bi-camera-video-off{{/if}}" aria-hidden="true" data-use-video-duration-icon></i><span class="visually-hidden">{{#if slide.useVideoDuration}}Disable use video duration{{else}}Use video duration{{/if}}</span></button>
|
||||
<button type="button" class="btn {{#if slide.disableAudio}}btn-outline-secondary{{else}}btn-secondary{{/if}} btn-sm playlist-disable-audio" data-disable-audio-button aria-pressed="{{#if slide.disableAudio}}true{{else}}false{{/if}}" aria-label="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" title="{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}" {{#unless slide.showMuteButton}}hidden{{/unless}}><i class="bi {{#if slide.disableAudio}}bi-volume-mute{{else}}bi-volume-up{{/if}}" aria-hidden="true" data-disable-audio-icon></i><span class="visually-hidden">{{#if slide.disableAudio}}Enable audio{{else}}Mute slide{{/if}}</span></button>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
|
||||
@@ -1,26 +1,26 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>{{#if isEdit}}Edit screen - {{screen.name}}{{else}}Add screen{{/if}}</h2>
|
||||
<h2>{{#if isEdit}}Edit screen group - {{screen.name}}{{else}}Add screen group{{/if}}</h2>
|
||||
<p>{{#if isEdit}}Update the display name and assigned playlist.{{else}}Register a player endpoint and connect it to a playlist.{{/if}}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12{{#if isEdit}} col-xl-8{{/if}}">
|
||||
<div class="card card-outline card-primary admin-form-card{{#if isEdit}} h-100{{/if}}">
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-primary admin-form-card h-100">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Screen details</h3>
|
||||
<h3 class="card-title">Screen group details</h3>
|
||||
</div>
|
||||
<form id="screen-form" method="post" action="{{formAction}}" {{{formAttrs}}}>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="screen-name" class="form-label">Name</label>
|
||||
<input id="screen-name" name="name" class="form-control" value="{{screen.name}}" placeholder="Front desk TV" required />
|
||||
<input id="screen-name" name="name" class="form-control" value="{{screen.name}}" maxlength="128" data-limit-text-length placeholder="Front desk TV" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="screen-slug" class="form-label">Slug</label>
|
||||
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" placeholder="front-desk-tv" />
|
||||
<input id="screen-slug" name="slug" class="form-control" value="{{screen.slug}}" maxlength="128" data-limit-text-length placeholder="front-desk-tv" />
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<label for="screen-playlist" class="form-label">Playlist</label>
|
||||
@@ -34,24 +34,24 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-secondary admin-form-card h-100">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Player URL</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if screen.player_url}}
|
||||
<p class="mb-0"><a href="{{screen.player_url}}" target="_blank">{{screen.player_url}}</a></p>
|
||||
{{else}}
|
||||
<p class="mb-0 text-muted">No player URL is available yet.</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-secondary admin-form-card h-100">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Player URL</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{{#if screen.player_url}}
|
||||
<p class="mb-0"><a href="{{screen.player_url}}" target="_blank">{{screen.player_url}}</a></p>
|
||||
{{else}}
|
||||
<p class="mb-0 text-muted">No player URL is available yet.</p>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1,20 +1,20 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Screens</h2>
|
||||
<h2>Screen Groups</h2>
|
||||
<p>Register display endpoints and keep playlist assignments in sync.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-primary" data-table-search-container data-table-pagination-card>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Existing screens</h3>
|
||||
<h3 class="card-title">Existing screen groups</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search screens" aria-label="Search screens" data-table-search />
|
||||
<input type="search" class="form-control" placeholder="Search screen groups" aria-label="Search screen groups" data-table-search />
|
||||
</div>
|
||||
{{#if (hasPermission currentUser 'screens.create')}}
|
||||
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen</a>
|
||||
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen group</a>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -56,7 +56,7 @@
|
||||
<a class="btn btn-sm btn-primary" href="/screens/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'screens.delete')}}
|
||||
<form class="inline-form" method="post" action="/screens/{{id}}/delete" data-confirm-message="Delete this screen?">
|
||||
<form class="inline-form" method="post" action="/screens/{{id}}/delete" data-confirm-message="Delete this screen group?">
|
||||
{{#if (gt player_connection_count 0)}}
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit" disabled>Delete</button>
|
||||
{{else}}
|
||||
@@ -72,7 +72,7 @@
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No screens yet.</td></tr>
|
||||
<tr data-table-search-empty-default><td colspan="5" class="empty">No screen groups yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
<div class="row g-3">
|
||||
<div class="col-6">
|
||||
<label for="slide-title" class="form-label">Title</label>
|
||||
<input id="slide-title" name="title" class="form-control" value="{{slide.title}}" required />
|
||||
<input id="slide-title" name="title" class="form-control" value="{{slide.title}}" maxlength="128" data-limit-text-length required />
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<label for="template-select" class="form-label">Template</label>
|
||||
@@ -118,9 +118,10 @@
|
||||
{{/if}}
|
||||
|
||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
||||
<script src="/assets/js/shared/qr-code-svg.js?v={{appVersion}}"></script>
|
||||
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
|
||||
<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
|
||||
<script src="/assets/vendor/qrcode-generator/qrcode.js?v={{appVersion}}"></script>
|
||||
<script src="/assets/vendor/qr-code-styling/qr-code-styling-loader.js?v={{appVersion}}"></script>
|
||||
{{#each slideEditorScripts}}
|
||||
<script type="module" src="/assets/{{this}}?v={{../appVersion}}"></script>
|
||||
{{/each}}
|
||||
|
||||
@@ -46,11 +46,14 @@
|
||||
<td data-label="Template">{{template_name}}</td>
|
||||
<td data-label="Playlists"><span class="chip">{{playlist_count}} playlist{{#if (gt playlist_count 1)}}s{{else}}{{/if}}{{#if (lt playlist_count 1)}}s{{/if}}</span></td>
|
||||
<td data-label="Actions">
|
||||
{{#if (anyPermission ../currentUser 'slides.update' 'slides.delete')}}
|
||||
{{#if (anyPermission ../currentUser 'slides.create' 'slides.update' 'slides.delete')}}
|
||||
<div class="actions">
|
||||
{{#if (hasPermission ../currentUser 'slides.update')}}
|
||||
<a class="btn btn-sm btn-primary" href="/slides/{{id}}/edit">Edit</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'slides.create')}}
|
||||
<a class="btn btn-sm btn-secondary" href="/slides/{{id}}/duplicate">Dupe</a>
|
||||
{{/if}}
|
||||
{{#if (hasPermission ../currentUser 'slides.delete')}}
|
||||
<form class="inline-form" method="post" action="/slides/{{id}}/delete" data-confirm-message="Delete this slide?">
|
||||
{{#if (gt playlist_count 0)}}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user