Release v2.15.2
This commit is contained in:
1 parent
ddcc665f81
commit
7295f83f04
43 files changed
+2526
-104
No files matched your search
@@ -3,7 +3,7 @@ const TASK = {
|
||||
title: 'Onboarding device prune',
|
||||
category: 'cleanup',
|
||||
trigger: 'scheduled recurring task, hourly',
|
||||
purpose: 'remove unbound onboarding devices that have been idle for more than one minute.',
|
||||
purpose: 'remove unbound pairings idle for more than 15 minutes and screen bindings idle for more than 24 hours.',
|
||||
taskType: 'recurring-run',
|
||||
intervalMs: 60 * 60 * 1000
|
||||
};
|
||||
|
||||
@@ -3709,28 +3709,48 @@ td[data-label="Actions"] > div {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.playlist-duration-input {
|
||||
.playlist-duration-input,
|
||||
.playlist-group-block-quota {
|
||||
width: 4rem !important;
|
||||
min-width: 4rem !important;
|
||||
flex: 0 0 4rem !important;
|
||||
min-width: 0 !important;
|
||||
flex: 1 1 4rem !important;
|
||||
}
|
||||
|
||||
.playlist-duration-input-group {
|
||||
.playlist-duration-input-group,
|
||||
.playlist-group-block-quota-wrap {
|
||||
display: inline-flex !important;
|
||||
flex: 0 0 auto !important;
|
||||
width: fit-content !important;
|
||||
max-width: fit-content !important;
|
||||
width: 6rem !important;
|
||||
max-width: 6rem !important;
|
||||
flex-wrap: nowrap;
|
||||
border-radius: var(--bs-border-radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.playlist-group-block-quota-wrap {
|
||||
width: calc(6rem + 0.5rem + 2rem) !important;
|
||||
max-width: calc(6rem + 0.5rem + 2rem) !important;
|
||||
}
|
||||
|
||||
.playlist-use-video-duration,
|
||||
.playlist-disable-audio,
|
||||
[data-playlist-group-shuffle] {
|
||||
flex: 0 0 2rem;
|
||||
width: 2rem;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
}
|
||||
|
||||
.playlist-duration-input::-webkit-outer-spin-button,
|
||||
.playlist-duration-input::-webkit-inner-spin-button {
|
||||
.playlist-duration-input::-webkit-inner-spin-button,
|
||||
.playlist-group-block-quota::-webkit-outer-spin-button,
|
||||
.playlist-group-block-quota::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.playlist-duration-input {
|
||||
.playlist-duration-input,
|
||||
.playlist-group-block-quota {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
@@ -3839,6 +3859,156 @@ table.table > :not(caption) > * > * {
|
||||
gap: 0.02rem;
|
||||
}
|
||||
|
||||
.playlist-group-block-row > td {
|
||||
padding: 0.5rem 0.5rem !important;
|
||||
border-top: 1px solid var(--bs-border-color) !important;
|
||||
border-bottom: 1px solid var(--bs-border-color) !important;
|
||||
background: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-table-bg)) !important;
|
||||
}
|
||||
|
||||
.playlist-group-icon {
|
||||
color: var(--playlist-group-accent, var(--bs-primary));
|
||||
}
|
||||
|
||||
.playlist-group-block-row > td[data-label="Actions"] {
|
||||
padding-right: 1.5rem !important;
|
||||
}
|
||||
|
||||
.playlist-group-block-row > td[data-label="Order"] {
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
|
||||
.playlist-group-block-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.playlist-group-block-slide {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.6rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.playlist-group-block-name {
|
||||
flex: 1 1 8rem;
|
||||
width: auto;
|
||||
min-width: 6rem;
|
||||
max-width: 24rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.playlist-group-mode {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
[data-playlist-group-select-controls][hidden],
|
||||
[data-playlist-group-repeat-controls][hidden] {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.playlist-group-child > td[data-label="Slide"] {
|
||||
padding-left: 1.5rem !important;
|
||||
}
|
||||
|
||||
.playlist-group-empty > td {
|
||||
height: 3.5rem;
|
||||
}
|
||||
|
||||
.playlist-group-child > td {
|
||||
background-color: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-table-bg));
|
||||
}
|
||||
|
||||
.table > tbody > tr.playlist-group-block-row > td {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.table > tbody > tr.playlist-group-child > td:first-child {
|
||||
box-shadow: inset 4px 0 var(--playlist-group-accent, var(--bs-primary)), inset 0 0 0 9999px var(--bs-table-bg-state, var(--bs-table-bg-type, var(--bs-table-accent-bg)));
|
||||
}
|
||||
|
||||
.playlist-schedule-conflict-indicator {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
margin-left: 0.35rem;
|
||||
border: 1px solid var(--bs-danger-border-subtle);
|
||||
background: var(--bs-danger-bg-subtle);
|
||||
color: var(--bs-danger-text-emphasis);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.playlist-group-block-dragging > td {
|
||||
opacity: 0.55;
|
||||
}
|
||||
|
||||
.playlist-group-block-placeholder > td {
|
||||
border-bottom: 1px dashed var(--bs-border-color) !important;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview {
|
||||
overflow: hidden;
|
||||
padding: 0.65rem;
|
||||
border: 1px solid var(--playlist-group-accent, var(--bs-primary));
|
||||
border-left-width: 4px;
|
||||
border-radius: 0.25rem;
|
||||
background: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-body-bg));
|
||||
box-shadow: 0 1rem 2.5rem rgba(0, 0, 0, 0.32);
|
||||
opacity: 0.98;
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding-bottom: 0.45rem;
|
||||
border-bottom: 1px solid var(--bs-border-color);
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview-members {
|
||||
padding-top: 0.35rem;
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview-member {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.55rem;
|
||||
min-width: 0;
|
||||
padding: 0.2rem 0;
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview-member img,
|
||||
.playlist-group-drag-preview-placeholder {
|
||||
flex: 0 0 2.5rem;
|
||||
width: 2.5rem;
|
||||
height: 1.45rem;
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview-placeholder {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--bs-secondary-bg);
|
||||
color: var(--bs-secondary-color);
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview-member > span:last-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playlist-group-drag-preview-more {
|
||||
padding: 0.25rem 0 0 3.05rem;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.playlist-drag-handle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -3901,6 +4071,10 @@ table.table > :not(caption) > * > * {
|
||||
opacity: 0.45;
|
||||
}
|
||||
|
||||
.playlist-row-ghost.playlist-group-child {
|
||||
background: color-mix(in srgb, var(--playlist-group-bg, var(--bs-primary-bg-subtle)) 45%, var(--bs-table-bg)) !important;
|
||||
}
|
||||
|
||||
.playlist-row-dragging {
|
||||
box-shadow: 0 0.85rem 2rem rgba(15, 23, 42, 0.18);
|
||||
opacity: 0.98;
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -14,11 +14,26 @@
|
||||
function measureDraggedRowWidths(row) {
|
||||
var metrics = {
|
||||
rowRect: row.getBoundingClientRect(),
|
||||
cellRects: []
|
||||
cellRects: [],
|
||||
rowStyles: {
|
||||
width: row.style.width,
|
||||
height: row.style.height,
|
||||
boxSizing: row.style.boxSizing
|
||||
},
|
||||
cellStyles: []
|
||||
};
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell) {
|
||||
metrics.cellRects.push(cell.getBoundingClientRect());
|
||||
metrics.cellStyles.push({
|
||||
width: cell.style.width,
|
||||
height: cell.style.height,
|
||||
boxSizing: cell.style.boxSizing,
|
||||
paddingLeft: cell.style.paddingLeft,
|
||||
paddingRight: cell.style.paddingRight,
|
||||
paddingTop: cell.style.paddingTop,
|
||||
paddingBottom: cell.style.paddingBottom
|
||||
});
|
||||
});
|
||||
|
||||
return metrics;
|
||||
@@ -61,19 +76,25 @@
|
||||
});
|
||||
}
|
||||
|
||||
function unlockDraggedRowWidths(row) {
|
||||
function unlockDraggedRowWidths(row, metrics) {
|
||||
if (!row) {
|
||||
return;
|
||||
}
|
||||
|
||||
row.style.width = '';
|
||||
row.style.height = '';
|
||||
row.style.boxSizing = '';
|
||||
var rowStyles = metrics && metrics.rowStyles ? metrics.rowStyles : {};
|
||||
row.style.width = rowStyles.width || '';
|
||||
row.style.height = rowStyles.height || '';
|
||||
row.style.boxSizing = rowStyles.boxSizing || '';
|
||||
|
||||
Array.prototype.forEach.call(row.children, function (cell) {
|
||||
cell.style.width = '';
|
||||
cell.style.height = '';
|
||||
cell.style.boxSizing = '';
|
||||
Array.prototype.forEach.call(row.children, function (cell, index) {
|
||||
var cellStyles = metrics && metrics.cellStyles && metrics.cellStyles[index] ? metrics.cellStyles[index] : {};
|
||||
cell.style.width = cellStyles.width || '';
|
||||
cell.style.height = cellStyles.height || '';
|
||||
cell.style.boxSizing = cellStyles.boxSizing || '';
|
||||
cell.style.paddingLeft = cellStyles.paddingLeft || '';
|
||||
cell.style.paddingRight = cellStyles.paddingRight || '';
|
||||
cell.style.paddingTop = cellStyles.paddingTop || '';
|
||||
cell.style.paddingBottom = cellStyles.paddingBottom || '';
|
||||
});
|
||||
}
|
||||
|
||||
@@ -130,6 +151,7 @@
|
||||
function initPlaylistTableDrag(options) {
|
||||
var tbody = options && options.tbody ? options.tbody : null;
|
||||
var onOrderChanged = options && typeof options.onOrderChanged === 'function' ? options.onOrderChanged : function () {};
|
||||
var onRowDrop = options && typeof options.onRowDrop === 'function' ? options.onRowDrop : function () {};
|
||||
var rowDragState = null;
|
||||
|
||||
if (!tbody) {
|
||||
@@ -148,6 +170,64 @@
|
||||
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]:not([data-playlist-ghost-row])'));
|
||||
}
|
||||
|
||||
function getDropTargets() {
|
||||
return Array.prototype.slice.call(tbody.children).filter(function (node) {
|
||||
return node === rowDragState.placeholder
|
||||
|| node.matches('[data-playlist-group-row]')
|
||||
|| (node.matches('[data-playlist-slide-row]') && !node.hasAttribute('data-playlist-ghost-row'));
|
||||
});
|
||||
}
|
||||
|
||||
function getGroupAtY(clientY) {
|
||||
var headers = Array.prototype.slice.call(tbody.querySelectorAll('[data-playlist-group-row]'));
|
||||
for (var index = 0; index < headers.length; index += 1) {
|
||||
var header = headers[index];
|
||||
var groupKey = String(header.getAttribute('data-group-key') || '');
|
||||
var top = header.getBoundingClientRect().top;
|
||||
var bottom = header.getBoundingClientRect().bottom;
|
||||
var member = header.nextElementSibling;
|
||||
while (member && member.matches('[data-playlist-slide-row], [data-playlist-group-empty]')
|
||||
&& String(member.getAttribute('data-group-key') || '') === groupKey) {
|
||||
bottom = member.getBoundingClientRect().bottom;
|
||||
member = member.nextElementSibling;
|
||||
}
|
||||
if (clientY >= top && clientY <= bottom) {
|
||||
return { key: groupKey, header: header };
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function syncDropTarget(clientY) {
|
||||
var groupTarget = getGroupAtY(clientY);
|
||||
var rows = getRows();
|
||||
var rowTarget = rows.find(function (row) {
|
||||
var rect = row.getBoundingClientRect();
|
||||
return clientY >= rect.top && clientY <= rect.bottom;
|
||||
}) || null;
|
||||
var nextHeader = groupTarget ? groupTarget.header : null;
|
||||
var nextRow = !groupTarget ? rowTarget : null;
|
||||
if (rowDragState.highlightedGroupHeader && rowDragState.highlightedGroupHeader !== nextHeader) {
|
||||
rowDragState.highlightedGroupHeader.classList.remove('playlist-group-drop-target');
|
||||
}
|
||||
if (rowDragState.highlightedRow && rowDragState.highlightedRow !== nextRow) {
|
||||
rowDragState.highlightedRow.classList.remove('playlist-row-drop-target');
|
||||
}
|
||||
if (nextHeader) nextHeader.classList.add('playlist-group-drop-target');
|
||||
if (nextRow) nextRow.classList.add('playlist-row-drop-target');
|
||||
rowDragState.highlightedGroupHeader = nextHeader;
|
||||
rowDragState.highlightedRow = nextRow;
|
||||
rowDragState.targetGroupKey = groupTarget ? groupTarget.key : '';
|
||||
}
|
||||
|
||||
function clearDropTarget() {
|
||||
if (!rowDragState) return;
|
||||
if (rowDragState.highlightedGroupHeader) rowDragState.highlightedGroupHeader.classList.remove('playlist-group-drop-target');
|
||||
if (rowDragState.highlightedRow) rowDragState.highlightedRow.classList.remove('playlist-row-drop-target');
|
||||
rowDragState.highlightedGroupHeader = null;
|
||||
rowDragState.highlightedRow = null;
|
||||
}
|
||||
|
||||
function getDragHandle(target) {
|
||||
return target && target.closest ? target.closest('[data-playlist-drag-handle]') : null;
|
||||
}
|
||||
@@ -242,30 +322,44 @@
|
||||
}
|
||||
|
||||
function repositionGhostRow(clientY) {
|
||||
var rows = getRows();
|
||||
var insertBeforeRow = null;
|
||||
var targets;
|
||||
var insertBeforeNode = null;
|
||||
var previousRects = new Map();
|
||||
var moved = false;
|
||||
syncDropTarget(clientY);
|
||||
if (rowDragState.placeholder) {
|
||||
rowDragState.placeholder.setAttribute('data-group-key', rowDragState.targetGroupKey || '');
|
||||
rowDragState.placeholder.classList.toggle('playlist-group-child', Boolean(rowDragState.targetGroupKey));
|
||||
['--playlist-group-accent', '--playlist-group-bg'].forEach(function (property) {
|
||||
var header = rowDragState.highlightedGroupHeader;
|
||||
if (header) {
|
||||
rowDragState.placeholder.style.setProperty(property, header.style.getPropertyValue(property));
|
||||
} else {
|
||||
rowDragState.placeholder.style.removeProperty(property);
|
||||
}
|
||||
});
|
||||
}
|
||||
targets = getDropTargets();
|
||||
|
||||
rows.forEach(function (row) {
|
||||
if (row !== rowDragState.placeholder) {
|
||||
previousRects.set(row, row.getBoundingClientRect());
|
||||
targets.forEach(function (node) {
|
||||
if (node !== rowDragState.placeholder) {
|
||||
previousRects.set(node, node.getBoundingClientRect());
|
||||
}
|
||||
});
|
||||
|
||||
for (var index = 0; index < rows.length; index += 1) {
|
||||
var candidateRow = rows[index];
|
||||
var rect = candidateRow.getBoundingClientRect();
|
||||
for (var index = 0; index < targets.length; index += 1) {
|
||||
var candidateNode = targets[index];
|
||||
var rect = candidateNode.getBoundingClientRect();
|
||||
|
||||
if (clientY < rect.top + rect.height / 2) {
|
||||
insertBeforeRow = candidateRow;
|
||||
insertBeforeNode = candidateNode;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (insertBeforeRow) {
|
||||
if (rowDragState.placeholder !== insertBeforeRow && rowDragState.placeholder.nextSibling !== insertBeforeRow) {
|
||||
tbody.insertBefore(rowDragState.placeholder, insertBeforeRow);
|
||||
if (insertBeforeNode) {
|
||||
if (rowDragState.placeholder !== insertBeforeNode && rowDragState.placeholder.nextSibling !== insertBeforeNode) {
|
||||
tbody.insertBefore(rowDragState.placeholder, insertBeforeNode);
|
||||
moved = true;
|
||||
}
|
||||
} else if (rowDragState.placeholder.parentNode !== tbody || rowDragState.placeholder.nextSibling) {
|
||||
@@ -310,7 +404,9 @@
|
||||
rowDragState.row.classList.remove('playlist-row-dragging');
|
||||
rowDragState.row.style.pointerEvents = rowDragState.previousPointerEvents;
|
||||
rowDragState.row.removeAttribute('aria-grabbed');
|
||||
unlockDraggedRowWidths(rowDragState.row);
|
||||
unlockDraggedRowWidths(rowDragState.row, rowDragState.metrics);
|
||||
onRowDrop(rowDragState.row, rowDragState.targetGroupKey || '');
|
||||
clearDropTarget();
|
||||
onOrderChanged();
|
||||
}
|
||||
|
||||
@@ -374,6 +470,7 @@
|
||||
metrics: measureDraggedRowWidths(row),
|
||||
startX: point.x,
|
||||
startY: point.y,
|
||||
targetGroupKey: String(row.getAttribute('data-group-key') || ''),
|
||||
currentPoint: point,
|
||||
threshold: 3,
|
||||
active: false,
|
||||
@@ -406,5 +503,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
window.animatePlaylistRowReorder = animateRowReorder;
|
||||
window.initPlaylistTableDrag = initPlaylistTableDrag;
|
||||
}());
|
||||
@@ -21,6 +21,27 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
const forwardPlayerCommandToDevice = deps.forwardPlayerCommandToDevice;
|
||||
const requirePermission = deps.requirePermission;
|
||||
|
||||
async function getScreenLiveConnectionState(screen) {
|
||||
if (typeof getScreenConnections !== 'function' || !String(screen && screen.slug || '').trim()) {
|
||||
return { verified: false, inUse: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await getScreenConnections(screen.slug);
|
||||
const connections = Array.isArray(response)
|
||||
? response
|
||||
: response && Array.isArray(response.connections)
|
||||
? response.connections
|
||||
: null;
|
||||
return {
|
||||
verified: Array.isArray(connections) && !Boolean(response && response.degraded),
|
||||
inUse: !Array.isArray(connections) || connections.length > 0
|
||||
};
|
||||
} catch (_error) {
|
||||
return { verified: false, inUse: true };
|
||||
}
|
||||
}
|
||||
|
||||
const SCREEN_NAME_MAX_LENGTH = 255;
|
||||
const SCREEN_SLUG_MAX_LENGTH = 255;
|
||||
|
||||
@@ -306,9 +327,20 @@ module.exports = function registerManageRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
||||
const forceDelete = String(req.body && req.body.force_delete || '') === '1';
|
||||
if (forceDelete) {
|
||||
const liveState = await getScreenLiveConnectionState(screen);
|
||||
if (!liveState.verified) {
|
||||
return res.redirect('/screens?message=' + encodeURIComponent('Unable to verify whether this screen has connected players. Try again.'));
|
||||
}
|
||||
if (liveState.inUse) {
|
||||
return res.redirect('/screens?message=' + encodeURIComponent('This screen is still in use by connected players.'));
|
||||
}
|
||||
} else {
|
||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, getScreenConnections);
|
||||
if (blockMessage) {
|
||||
return res.redirect('/screens?message=' + encodeURIComponent(blockMessage));
|
||||
}
|
||||
}
|
||||
await pool.query('DELETE FROM d_screens WHERE id = ?', [screen.id]);
|
||||
if (typeof recordRequestAuditEvent === 'function') await recordRequestAuditEvent(pool, req, { category: 'screens', eventType: 'screen.deleted', actorUserId: getAuditUserId(req), targetType: 'screen', targetId: screen.id, targetLabel: screen.name });
|
||||
|
||||
@@ -146,6 +146,13 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
const durations = readArrayField(reqBody, ['duration_seconds[]', 'duration_seconds']);
|
||||
const useVideoDurations = readArrayField(reqBody, ['use_video_duration[]', 'use_video_duration']);
|
||||
const disableAudios = readArrayField(reqBody, ['disable_audio[]', 'disable_audio']);
|
||||
const groupKeys = readRawFieldArray(reqBody, ['group_key[]', 'group_key']);
|
||||
const groupNames = readRawFieldArray(reqBody, ['group_name[]', 'group_name']);
|
||||
const groupPickCounts = readRawFieldArray(reqBody, ['group_pick_count[]', 'group_pick_count']);
|
||||
const groupShuffles = readRawFieldArray(reqBody, ['group_shuffle[]', 'group_shuffle']);
|
||||
const groupModes = readRawFieldArray(reqBody, ['group_mode[]', 'group_mode']);
|
||||
const groupRepeatCounts = readRawFieldArray(reqBody, ['group_repeat_count[]', 'group_repeat_count']);
|
||||
const groupScheduleRulesJson = readRawFieldArray(reqBody, ['group_schedule_rules_json[]', 'group_schedule_rules_json']);
|
||||
const scheduleRules = buildScheduleRulesFromRequestBody(reqBody);
|
||||
|
||||
if (durations.length && durations.length !== slideIds.length) {
|
||||
@@ -157,6 +164,11 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
if (disableAudios.length && disableAudios.length !== slideIds.length) {
|
||||
throw createValidationError('Playlist slide data is invalid.');
|
||||
}
|
||||
if ([groupKeys, groupNames, groupPickCounts, groupShuffles, groupModes, groupRepeatCounts, groupScheduleRulesJson].some(function (values) {
|
||||
return values.length && values.length !== slideIds.length;
|
||||
})) {
|
||||
throw createValidationError('Playlist group data is invalid.');
|
||||
}
|
||||
if (rowKeys.length && rowKeys.length !== slideIds.length) {
|
||||
throw createValidationError('Playlist slide data is invalid.');
|
||||
}
|
||||
@@ -190,6 +202,58 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
|
||||
const disableAudio = String(disableAudios[i] || '') !== '0' ? 1 : 0;
|
||||
const rowKey = String(rowKeys[i] || '').trim();
|
||||
const groupKey = String(groupKeys[i] || '').trim();
|
||||
const groupName = String(groupNames[i] || '').trim();
|
||||
const groupPickCount = Number(groupPickCounts[i] || 1);
|
||||
const groupShuffle = String(groupShuffles[i] || '0') === '1' ? 1 : 0;
|
||||
const groupMode = String(groupModes[i] || 'select');
|
||||
const groupRepeatCount = Number(groupRepeatCounts[i] || 1);
|
||||
if (!['select', 'repeat'].includes(groupMode) || !Number.isInteger(groupRepeatCount) || groupRepeatCount < 1 || groupRepeatCount > 100) {
|
||||
throw createValidationError('Playlist group repeat settings are invalid.');
|
||||
}
|
||||
let groupScheduleRules = [];
|
||||
if (groupScheduleRulesJson[i]) {
|
||||
try {
|
||||
const parsedRules = JSON.parse(groupScheduleRulesJson[i]);
|
||||
if (!Array.isArray(parsedRules)) {
|
||||
throw new Error('Group schedule rules must be an array.');
|
||||
}
|
||||
groupScheduleRules = parsedRules.map(function (rule) {
|
||||
if (!rule || typeof rule !== 'object' || Array.isArray(rule)) {
|
||||
throw new Error('Group schedule rule is invalid.');
|
||||
}
|
||||
const startDatetime = String(rule.start_datetime || '').trim() || null;
|
||||
const endDatetime = String(rule.end_datetime || '').trim() || null;
|
||||
const startTime = String(rule.start_time || '').trim() || null;
|
||||
const endTime = String(rule.end_time || '').trim() || null;
|
||||
const days = Array.isArray(rule.days) ? Array.from(new Set(rule.days.map(Number))) : [];
|
||||
if (Boolean(startDatetime) !== Boolean(endDatetime) || Boolean(startTime) !== Boolean(endTime)
|
||||
|| (startDatetime && (Number.isNaN(new Date(startDatetime).getTime()) || Number.isNaN(new Date(endDatetime).getTime()) || new Date(endDatetime) < new Date(startDatetime)))
|
||||
|| (startTime && (!/^\d{2}:\d{2}(?::\d{2})?$/.test(startTime) || !/^\d{2}:\d{2}(?::\d{2})?$/.test(endTime) || endTime < startTime))
|
||||
|| days.some(function (day) { return !Number.isInteger(day) || day < 0 || day > 6; })) {
|
||||
throw new Error('Group schedule rule is invalid.');
|
||||
}
|
||||
const normalizedRule = {};
|
||||
if (startDatetime) normalizedRule.start_datetime = startDatetime;
|
||||
if (endDatetime) normalizedRule.end_datetime = endDatetime;
|
||||
if (startTime) normalizedRule.start_time = startTime;
|
||||
if (endTime) normalizedRule.end_time = endTime;
|
||||
if (days.length) normalizedRule.days = days.sort(function (left, right) { return left - right; });
|
||||
if (!Object.keys(normalizedRule).length) {
|
||||
throw new Error('Group schedule rule is empty.');
|
||||
}
|
||||
return normalizedRule;
|
||||
});
|
||||
} catch (_error) {
|
||||
throw createValidationError('Playlist group schedule data is invalid.');
|
||||
}
|
||||
}
|
||||
if (groupKey && (!/^[a-zA-Z0-9_-]{1,64}$/.test(groupKey) || !groupName || groupName.length > 128 || !Number.isInteger(groupPickCount) || groupPickCount < 1)) {
|
||||
throw createValidationError('Playlist group data is invalid.');
|
||||
}
|
||||
if (!groupKey && (groupName || groupScheduleRules.length)) {
|
||||
throw createValidationError('Playlist group data is invalid.');
|
||||
}
|
||||
const playlistScheduleRules = scheduleRulesByRowKey.get(rowKey) || [];
|
||||
for (const rule of playlistScheduleRules) {
|
||||
if (rule.start_datetime && rule.end_datetime && new Date(rule.end_datetime) < new Date(rule.start_datetime)) {
|
||||
@@ -207,10 +271,43 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
durationSeconds,
|
||||
useVideoDuration,
|
||||
disableAudio,
|
||||
groupKey: groupKey || null,
|
||||
groupName: groupKey ? groupName : null,
|
||||
groupPickCount: groupKey ? groupPickCount : null,
|
||||
groupShuffle: groupKey ? groupShuffle : 0,
|
||||
groupMode: groupKey ? groupMode : 'select',
|
||||
groupRepeatCount: groupKey ? groupRepeatCount : 1,
|
||||
groupScheduleRules: groupKey ? groupScheduleRules : [],
|
||||
scheduleRules: playlistScheduleRules
|
||||
});
|
||||
}
|
||||
|
||||
const groupSettings = new Map();
|
||||
const groupSizes = new Map();
|
||||
normalizedSlides.forEach(function (item) {
|
||||
if (item.groupKey) groupSizes.set(item.groupKey, (groupSizes.get(item.groupKey) || 0) + 1);
|
||||
});
|
||||
normalizedSlides.forEach(function (item) {
|
||||
if (!item.groupKey) {
|
||||
return;
|
||||
}
|
||||
const serializedRules = JSON.stringify(item.groupScheduleRules);
|
||||
if (item.groupPickCount > groupSizes.get(item.groupKey)) {
|
||||
throw createValidationError('Group pick count cannot exceed the number of slides in the group.');
|
||||
}
|
||||
const settings = { name: item.groupName, pickCount: item.groupPickCount, shuffle: item.groupShuffle, mode: item.groupMode, repeatCount: item.groupRepeatCount, rules: serializedRules };
|
||||
const previousSettings = groupSettings.get(item.groupKey);
|
||||
if (previousSettings && (previousSettings.name.toLowerCase() !== settings.name.toLowerCase()
|
||||
|| previousSettings.pickCount !== settings.pickCount || previousSettings.shuffle !== settings.shuffle
|
||||
|| previousSettings.mode !== settings.mode || previousSettings.repeatCount !== settings.repeatCount || previousSettings.rules !== settings.rules)) {
|
||||
throw createValidationError('All slides in a playlist group must use the same group settings.');
|
||||
}
|
||||
if (!previousSettings) {
|
||||
groupSettings.set(item.groupKey, settings);
|
||||
}
|
||||
item.groupName = groupSettings.get(item.groupKey).name;
|
||||
});
|
||||
|
||||
let selectedCanvasId = null;
|
||||
if (normalizedSlides.length) {
|
||||
const [slides] = await connection.query(
|
||||
@@ -249,8 +346,8 @@ module.exports = function registerPlaylistRoutes(app, deps) {
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
const [insertResult] = await connection.query(
|
||||
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, actorId, actorId]
|
||||
'INSERT INTO c_playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, disable_audio, group_key, group_name, group_pick_count, group_schedule_rules_json, group_shuffle, group_mode, group_repeat_count, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[playlist.id, item.slideId, item.position, item.durationSeconds, item.useVideoDuration, item.disableAudio, item.groupKey, item.groupName, item.groupPickCount, item.groupKey ? JSON.stringify(item.groupScheduleRules) : null, item.groupShuffle, item.groupMode, item.groupRepeatCount, actorId, actorId]
|
||||
);
|
||||
const insertedPlaylistSlideId = Number(insertResult.insertId);
|
||||
const itemRules = Array.isArray(item.scheduleRules) ? item.scheduleRules : [];
|
||||
|
||||
@@ -203,6 +203,22 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
|
||||
useVideoDuration: Boolean(item.use_video_duration),
|
||||
disableAudio: getDisableAudioValue(item),
|
||||
durationSeconds: getVideoDurationValue(item),
|
||||
groupKey: item.group_key || '',
|
||||
groupName: item.group_name || '',
|
||||
groupPickCount: Number(item.group_pick_count) || 1,
|
||||
groupShuffle: Boolean(Number(item.group_shuffle)),
|
||||
groupMode: item.group_mode === 'repeat' ? 'repeat' : 'select',
|
||||
groupRepeatCount: Math.min(100, Math.max(1, Number(item.group_repeat_count) || 1)),
|
||||
groupScheduleRules: Array.isArray(item.group_schedule_rules_json)
|
||||
? item.group_schedule_rules_json
|
||||
: (function () {
|
||||
try {
|
||||
const parsed = JSON.parse(item.group_schedule_rules_json || '[]');
|
||||
return Array.isArray(parsed) ? parsed : [];
|
||||
} catch (_error) {
|
||||
return [];
|
||||
}
|
||||
}()),
|
||||
scheduleRules: (Array.isArray(item.scheduleRules) ? item.scheduleRules : []).map(formatScheduleRuleForView),
|
||||
scheduleSummary: scheduleSummary(item)
|
||||
}));
|
||||
@@ -282,7 +298,7 @@ function buildPlaylistFormViewModel(playlist, data, message, currentUser, option
|
||||
playlistCanvasId: playlistCanvasId,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
defaultSlideDurationSeconds: defaultSlideDurationSeconds,
|
||||
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20260802.5', 'js/playlists/playlist-schedule.js?v=20260816.1'],
|
||||
scripts: ['js/lib/modal.js?v=20260726.7', 'js/playlists/playlist-table-drag.js?v=20261014.7', 'js/playlists/playlist-schedule.js?v=20261015.8'],
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
inUse: Boolean(viewPlaylist.inUse)
|
||||
|
||||
@@ -40,6 +40,7 @@ function formatTimeInput(value) {
|
||||
}
|
||||
|
||||
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
|
||||
const scheduleHeading = String(rowKey || '').indexOf('group:') === 0 ? 'Group schedule' : 'Slide schedule';
|
||||
const slide = playlistSlide || {
|
||||
id: 0,
|
||||
scheduleRules: []
|
||||
@@ -72,7 +73,8 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
|
||||
}));
|
||||
|
||||
return renderFragment('playlists/slide-config', {
|
||||
title: 'Slide schedule',
|
||||
title: scheduleHeading,
|
||||
scheduleHeading: scheduleHeading,
|
||||
framePopupCard: true,
|
||||
hideFrameHeader: true,
|
||||
hideThemeToggle: true,
|
||||
|
||||
@@ -19,6 +19,7 @@ function buildScreenFormViewModel(screen, data, message, currentUser, isEdit) {
|
||||
showSaveSecondaryActions: true,
|
||||
deleteDisabled: Boolean(isEdit) ? Boolean(viewScreen.inUse) : true,
|
||||
deleteConfirmMessage: Boolean(isEdit) ? 'Delete this screen?' : '',
|
||||
forceDeleteUrl: Boolean(isEdit) && viewScreen.id ? '/screens/' + viewScreen.id + '/delete' : '',
|
||||
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"'
|
||||
|
||||
@@ -81,6 +81,36 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
return common.fetchPlayerPublicBaseUrl(pool);
|
||||
}
|
||||
|
||||
async function getScreenLiveConnectionState(screen) {
|
||||
if (typeof deps.getScreenConnections !== 'function' || !String(screen && screen.slug || '').trim()) {
|
||||
return { verified: false, inUse: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await deps.getScreenConnections(screen.slug);
|
||||
const connections = Array.isArray(response)
|
||||
? response
|
||||
: response && Array.isArray(response.connections)
|
||||
? response.connections
|
||||
: null;
|
||||
return {
|
||||
verified: Array.isArray(connections) && !Boolean(response && response.degraded),
|
||||
inUse: !Array.isArray(connections) || connections.length > 0
|
||||
};
|
||||
} catch (_error) {
|
||||
return { verified: false, inUse: true };
|
||||
}
|
||||
}
|
||||
|
||||
async function canForceDeleteScreen(screen, blockMessage) {
|
||||
if (blockMessage !== 'This screen is still linked to onboarding devices.') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const liveState = await getScreenLiveConnectionState(screen);
|
||||
return liveState.verified && !liveState.inUse;
|
||||
}
|
||||
|
||||
function buildLauncherContent(templatePath, playerUrl) {
|
||||
const template = fs.readFileSync(templatePath, 'utf8');
|
||||
const normalizedPlayerUrl = String(playerUrl || '').trim();
|
||||
@@ -110,7 +140,9 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections);
|
||||
screen.inUse = Boolean(blockMessage);
|
||||
screen.canForceDelete = await canForceDeleteScreen(screen, blockMessage);
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
}
|
||||
@@ -135,7 +167,9 @@ module.exports = function registerScreensRoutes(app, deps) {
|
||||
if (!screen) {
|
||||
return res.status(404).send('Screen not found');
|
||||
}
|
||||
screen.inUse = Boolean(await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections));
|
||||
const blockMessage = await getScreenDeleteBlockMessage(pool, screen, deps.getScreenConnections);
|
||||
screen.inUse = Boolean(blockMessage);
|
||||
screen.canForceDelete = await canForceDeleteScreen(screen, blockMessage);
|
||||
const editData = await common.fetchScreenEditData(pool);
|
||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||
} catch (error) {
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2" data-api-source-request-section>
|
||||
<div class="mt-3 mb-2" data-api-source-request-section>
|
||||
<h4 class="api-source-section-heading">Request</h4>
|
||||
<div class="row g-3 align-items-end">
|
||||
<div class="col-12">
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261015.6" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
<link rel="stylesheet" href="/assets/{{this}}" />
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<link rel="stylesheet" href="/assets/adminlte/bootstrap-icons/css/bootstrap-icons.min.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte.min.css" />
|
||||
<link rel="stylesheet" href="/assets/adminlte/css/adminlte-colors.min.css" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}" />
|
||||
<link rel="stylesheet" href="/assets/css/theme-custom.css?v={{appVersion}}&ui=20261015.6" />
|
||||
<link rel="stylesheet" href="/assets/vendor/animate.css/animate.min.css?v={{appVersion}}" />
|
||||
{{#if stylesheets.length}}
|
||||
{{#each stylesheets}}
|
||||
|
||||
@@ -58,9 +58,10 @@
|
||||
<div class="row g-4 mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card card-outline card-secondary mb-0">
|
||||
<div class="card-header bg-body-tertiary">
|
||||
<h3 class="card-title">Playlist items</h3>
|
||||
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||
<div class="card-header bg-body-tertiary d-flex flex-wrap align-items-center row-gap-2">
|
||||
<h3 class="card-title mb-0">Playlist items</h3>
|
||||
<div class="card-tools d-flex flex-wrap align-items-center gap-2 my-0 me-0 ms-auto">
|
||||
<button type="button" class="btn btn-outline-primary btn-sm" id="playlist-create-group"><i class="bi bi-collection" aria-hidden="true"></i> New group</button>
|
||||
<button type="button" class="btn btn-primary btn-sm" id="playlist-open-slide-modal" data-bs-toggle="modal" data-bs-target="#playlist-add-slide-modal" {{#unless playlistCanvasId}}disabled{{/unless}}>Add slide</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="card card-outline card-primary mb-0 slide-schedule-card">
|
||||
<div class="card-header d-flex align-items-center gap-2">
|
||||
<h3 class="card-title mb-0">Slide schedule</h3>
|
||||
<h3 class="card-title mb-0">{{scheduleHeading}}</h3>
|
||||
<div class="card-tools ms-auto">
|
||||
<button type="button" class="btn btn-primary btn-sm" id="schedule-add-rule">Add another rule</button>
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<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}}">
|
||||
<tr data-playlist-slide-row data-row-key="{{slide.row_key}}" data-slide-id="{{slide.slide_id}}" data-group-key="{{slide.groupKey}}" 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">
|
||||
<div class="playlist-order-stepper" aria-label="Reorder slide controls">
|
||||
@@ -24,6 +24,13 @@
|
||||
</div>
|
||||
<input type="hidden" name="slide_id[]" value="{{slide.slide_id}}" form="playlist-edit-form" data-playlist-slide-id-input />
|
||||
<input type="hidden" name="row_key[]" value="{{slide.row_key}}" form="playlist-edit-form" data-playlist-row-key-input />
|
||||
<input type="hidden" name="group_key[]" value="{{slide.groupKey}}" form="playlist-edit-form" data-playlist-group-key />
|
||||
<input type="hidden" name="group_name[]" value="{{slide.groupName}}" form="playlist-edit-form" data-playlist-group-name-value />
|
||||
<input type="hidden" name="group_pick_count[]" value="{{#if slide.groupPickCount}}{{slide.groupPickCount}}{{else}}1{{/if}}" form="playlist-edit-form" data-playlist-group-pick-count-value />
|
||||
<input type="hidden" name="group_shuffle[]" value="{{#if slide.groupShuffle}}1{{else}}0{{/if}}" form="playlist-edit-form" data-playlist-group-shuffle-value />
|
||||
<input type="hidden" name="group_mode[]" value="{{#if slide.groupMode}}{{slide.groupMode}}{{else}}select{{/if}}" form="playlist-edit-form" data-playlist-group-mode-value />
|
||||
<input type="hidden" name="group_repeat_count[]" value="{{#if slide.groupRepeatCount}}{{slide.groupRepeatCount}}{{else}}1{{/if}}" form="playlist-edit-form" data-playlist-group-repeat-count-value />
|
||||
<input type="hidden" name="group_schedule_rules_json[]" value="{{#if slide.groupScheduleRules}}{{json slide.groupScheduleRules}}{{else}}[]{{/if}}" form="playlist-edit-form" data-playlist-group-schedule-rules />
|
||||
</td>
|
||||
<td data-label="Rules">
|
||||
<div class="playlist-schedule-summary"><span class="chip" data-schedule-summary-chip>{{slide.scheduleSummary}}</span></div>
|
||||
@@ -33,7 +40,7 @@
|
||||
<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="{{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 />
|
||||
<input name="duration_seconds[]" type="number" inputmode="decimal" min="1" step="any" 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 />
|
||||
|
||||
@@ -33,10 +33,20 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
<div class="card-footer d-flex align-items-center gap-2">
|
||||
{{#if screen.canForceDelete}}
|
||||
<button type="submit" form="screen-force-delete-form" class="btn btn-outline-danger">Force delete</button>
|
||||
{{/if}}
|
||||
<div class="ms-auto">
|
||||
{{{saveActionButtons formId="screen-form" saveUrl=formAction cancelUrl=cancelUrl deleteUrl=deleteUrl deleteConfirmMessage=deleteConfirmMessage deleteDisabled=deleteDisabled showSaveAndClose=showSaveSecondaryActions showSaveAndNew=showSaveSecondaryActions}}}
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
{{#if screen.canForceDelete}}
|
||||
<form id="screen-force-delete-form" method="post" action="{{forceDeleteUrl}}" data-confirm-message="Force delete this screen group and unlink its onboarding devices?">
|
||||
<input type="hidden" name="force_delete" value="1" />
|
||||
</form>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in new issue
Block a user