Release v2.5.6

This commit is contained in:
2026-08-05 19:38:16 +01:00
parent a8ef35b287
commit 9f831f04bc
161 changed files with 8487 additions and 1295 deletions
+14 -2
View File
@@ -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>');
+34 -3
View File
@@ -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 };
+4 -2
View File
@@ -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 + ' />'
: '';
}
-3
View File
@@ -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 });
+459 -22
View File
@@ -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,
+129 -6
View File
@@ -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) {
+590 -40
View File
@@ -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();
+82 -12
View File
@@ -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]'));
});
}
+47 -13
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
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
};
}());
+97 -15
View File
@@ -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;
-1
View File
@@ -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>' +
+7 -13
View File
@@ -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 -37
View File
@@ -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
+12 -3
View File
@@ -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,
+6 -18
View File
@@ -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', {
+1 -1
View File
@@ -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>' +
+35 -38
View File
@@ -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) {
+6 -13
View File
@@ -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', {
+54
View File
@@ -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
};
}());
+158 -46
View File
@@ -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) {
+97 -6
View File
@@ -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,
+96 -19
View File
@@ -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;
+301 -52
View File
@@ -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();
})();
+66
View File
@@ -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 = {
+209 -3
View File
@@ -10,7 +10,7 @@
.replace(/'/g, '&#39;');
}
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
+5 -3
View File
@@ -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);
}
+84 -7
View File
@@ -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', {
+24 -4
View File
@@ -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);
+47 -12
View File
@@ -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
+63 -16
View File
@@ -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);
}
+18 -5
View File
@@ -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 -1
View File
@@ -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 {
+2
View File
@@ -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,
+3 -1
View File
@@ -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()}`,
+20
View File
@@ -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',
+15 -1
View File
@@ -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
});
+43 -8
View File
@@ -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"',
+9 -2
View File
@@ -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"'
+7 -2
View File
@@ -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])
};
}
+5 -4
View File
@@ -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>
+3
View File
@@ -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}}
+56 -55
View File
@@ -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}}
+1 -1
View File
@@ -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>
+52 -28
View File
@@ -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">&mdash;</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>
+3
View File
@@ -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">
+7 -6
View File
@@ -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>
+4 -1
View File
@@ -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>
+10 -10
View File
@@ -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">
+72 -39
View File
@@ -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}}
+8 -5
View File
@@ -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>
+6 -3
View File
@@ -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}}}
+4 -1
View File
@@ -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)}}
+12 -12
View File
@@ -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>
+7 -7
View File
@@ -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>
+2 -2
View File
@@ -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}}
+6 -3
View File
@@ -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>
+12 -13
View File
@@ -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">
+19 -19
View File
@@ -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>
+6 -6
View File
@@ -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>
+3 -2
View File
@@ -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}}
+4 -1
View File
@@ -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