Release v1.1.3: playlist drag sorting, local SortableJS, and playlist UI updates
Publish Docker Image / build-and-push (push) Successful in 27s

This commit is contained in:
2026-07-15 00:45:40 +01:00
parent b0649d838e
commit b7f1d800ef
9 changed files with 164 additions and 54 deletions
+4 -6
View File
@@ -1,6 +1,4 @@
node_modules
npm-debug.log
uploads
.git
.gitignore
*.tmp
*
!package.json
!src/
!src/**
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "1.1.2",
"version": "1.1.3",
"private": false,
"description": "Pulse Signage application with MySQL and media uploads",
"repository": {
+106 -1
View File
@@ -1876,6 +1876,107 @@ table td .actions form {
align-items: center;
}
.playlist-order-cell {
width: 88px;
min-width: 88px;
white-space: nowrap;
vertical-align: middle;
}
.playlist-order-cell-inner {
display: flex;
align-items: center;
gap: 8px;
min-height: 100%;
}
.playlist-order-number {
min-width: 1.5em;
font-weight: 800;
color: var(--muted);
}
.playlist-drag-handle {
flex: 0 0 auto;
width: 28px;
height: 28px;
padding: 0;
border: 0;
border-radius: 0;
color: var(--muted);
background: currentColor;
-webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 4L8 8H16L12 4Z'/%3E%3Cpath d='M4 11H20V13H4V11Z'/%3E%3Cpath d='M4 15H20V17H4V15Z'/%3E%3Cpath d='M4 7H20V9H4V7Z'/%3E%3Cpath d='M12 20L8 16H16L12 20Z'/%3E%3C/svg%3E") center / contain no-repeat;
mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24' fill='black'%3E%3Cpath d='M12 4L8 8H16L12 4Z'/%3E%3Cpath d='M4 11H20V13H4V11Z'/%3E%3Cpath d='M4 15H20V17H4V15Z'/%3E%3Cpath d='M4 7H20V9H4V7Z'/%3E%3Cpath d='M12 20L8 16H16L12 20Z'/%3E%3C/svg%3E") center / contain no-repeat;
cursor: grab;
transition: color 160ms ease;
}
.playlist-drag-handle {
color: var(--muted);
}
.playlist-drag-handle:hover,
.playlist-drag-handle:focus-visible {
color: var(--primary);
}
html[data-theme='dark'] .playlist-drag-handle {
color: var(--sidebar-text);
}
html[data-theme='dark'] .playlist-drag-handle:hover,
html[data-theme='dark'] .playlist-drag-handle:focus-visible {
color: var(--primary);
}
.playlist-drag-handle:active,
tr.is-dragging .playlist-drag-handle {
cursor: grabbing;
}
tr.sortable-chosen {
opacity: 0.55;
}
tr.sortable-ghost {
opacity: 0.32;
background: var(--primary-soft);
}
tr.sortable-drag {
box-shadow: var(--shadow-md);
background: var(--surface);
}
@media (min-width: 721px) {
table.table-cards tr > td:nth-child(1),
table.table-cards tr > th:nth-child(1) {
width: 1%;
white-space: nowrap;
}
table.table-cards tr > td:nth-child(2),
table.table-cards tr > th:nth-child(2) {
width: 36%;
}
table.table-cards tr > td:nth-child(3),
table.table-cards tr > th:nth-child(3) {
width: 36%;
}
table.table-cards tr > td:nth-child(4),
table.table-cards tr > th:nth-child(4) {
width: 10%;
}
table.table-cards tr > td:nth-child(5),
table.table-cards tr > th:nth-child(5) {
width: 1%;
white-space: nowrap;
}
}
.actions form {
display: inline;
}
@@ -1911,6 +2012,11 @@ table td .actions form {
margin-left: 6px;
}
html[data-theme='dark'] .chip {
background: rgba(148, 163, 184, 0.16);
color: var(--sidebar-text);
}
.slide-editor-top {
display: grid;
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
@@ -1993,7 +2099,6 @@ table td .actions form {
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.25);
background: rgba(15, 23, 42, 0.2);
backdrop-filter: blur(1px);
}
.slide-preview-region h1,
+31 -34
View File
@@ -230,7 +230,6 @@
var addSlideForm = document.getElementById('playlist-add-slide-form');
var addSlideButton = document.getElementById('playlist-add-slide-button');
var addSlideSelect = document.getElementById('playlist-add-slide-select');
var addDurationInput = document.getElementById('playlist-add-duration');
var addSlideEmpty = document.getElementById('playlist-add-slide-empty');
if (!tbody || !form) {
@@ -351,9 +350,6 @@
if (addSlideSelect) {
addSlideSelect.disabled = availableCount === 0;
}
if (addDurationInput) {
addDurationInput.disabled = availableCount === 0;
}
if (addSlideEmpty) {
addSlideEmpty.style.display = availableCount === 0 ? '' : 'none';
}
@@ -366,17 +362,9 @@
function updateRowOrder() {
var rows = getRows();
rows.forEach(function (row, index) {
var orderCell = row.querySelector('.playlist-order-cell');
var moveUp = row.querySelector('[data-playlist-move="up"]');
var moveDown = row.querySelector('[data-playlist-move="down"]');
if (orderCell) {
orderCell.textContent = String(index + 1);
}
if (moveUp) {
moveUp.disabled = index === 0;
}
if (moveDown) {
moveDown.disabled = index === rows.length - 1;
var orderNumber = row.querySelector('.playlist-order-number');
if (orderNumber) {
orderNumber.textContent = String(index + 1);
}
});
syncAddSlideOptions();
@@ -423,9 +411,13 @@
row.setAttribute('data-slide-id', String(values.slide_id));
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
row.innerHTML = '' +
'<td class="playlist-order-cell"></td>' +
'<td class="playlist-order-cell" data-label="Order">' +
'<div class="playlist-order-cell-inner">' +
'<button type="button" class="playlist-drag-handle" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"></button>' +
'<span class="playlist-order-number"></span>' +
'</div>' +
'</td>' +
'<td>' + values.title + '<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" /></td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required /></td>' +
'<td>' +
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" />' +
@@ -435,9 +427,8 @@
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" />' +
'</td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required /></td>' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="secondary" data-playlist-move="up">Up</button>' +
'<button type="button" class="secondary" data-playlist-move="down">Down</button>' +
'<button type="button" class="schedule-config-button" disabled>Schedule</button>' +
'<button type="button" class="danger" data-playlist-remove-row>Remove</button>' +
'</div></td>';
@@ -445,7 +436,6 @@
}
tbody.addEventListener('click', function (event) {
var moveButton = event.target.closest('[data-playlist-move]');
var removeButton = event.target.closest('[data-playlist-remove-row]');
var scheduleButton = event.target.closest('[data-schedule-config]');
var row = event.target.closest('tr[data-playlist-slide-row]');
@@ -454,18 +444,6 @@
return;
}
if (moveButton) {
event.preventDefault();
var direction = moveButton.getAttribute('data-playlist-move');
if (direction === 'up' && row.previousElementSibling && row.previousElementSibling.matches('[data-playlist-slide-row]')) {
tbody.insertBefore(row, row.previousElementSibling);
} else if (direction === 'down' && row.nextElementSibling && row.nextElementSibling.matches('[data-playlist-slide-row]')) {
tbody.insertBefore(row.nextElementSibling, row);
}
updateRowOrder();
return;
}
if (removeButton) {
event.preventDefault();
row.remove();
@@ -481,7 +459,26 @@
}
});
if (addSlideButton && addSlideSelect && addDurationInput) {
if (window.Sortable) {
Sortable.create(tbody, {
animation: 180,
handle: '[data-playlist-drag-handle]',
draggable: 'tr[data-playlist-slide-row]',
ghostClass: 'sortable-ghost',
chosenClass: 'sortable-chosen',
dragClass: 'sortable-drag',
forceFallback: true,
fallbackOnBody: true,
fallbackTolerance: 3,
swapThreshold: 0.65,
invertedSwapThreshold: 0.65,
onEnd: function () {
updateRowOrder();
}
});
}
if (addSlideButton && addSlideSelect) {
addSlideButton.addEventListener('click', function () {
var selectedOption = addSlideSelect.options[addSlideSelect.selectedIndex];
if (!selectedOption || selectedOption.disabled) {
@@ -496,7 +493,7 @@
slide_id: String(selectedOption.value),
canvas_signature: String(selectedOption.getAttribute('data-canvas-signature') || ''),
title: selectedOption.textContent || 'Slide',
duration_seconds: Math.max(1, Number(addDurationInput.value || 10)),
duration_seconds: 10,
schedule_mode: 'always',
schedule_start_datetime: '',
schedule_end_datetime: '',
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -110,6 +110,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/playlist-schedule.js']
scripts: ['js/sortable.min.js', 'js/playlist-schedule.js']
});
};
+4
View File
@@ -10,6 +10,10 @@ Handlebars.registerHelper('eq', function (left, right) {
return left === right;
});
Handlebars.registerHelper('isExternalUrl', function (value) {
return /^https?:\/\//i.test(String(value || ''));
});
Handlebars.registerHelper('playerUrl', function (slug) {
const base = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
return `${base}/screen/${encodeURIComponent(slug)}`;
+5 -1
View File
@@ -105,7 +105,11 @@
{{/if}}
{{#if scripts.length}}
{{#each scripts}}
<script src="/assets/{{this}}"></script>
{{#if (isExternalUrl this)}}
<script src="{{this}}"></script>
{{else}}
<script src="/assets/{{this}}"></script>
{{/if}}
{{/each}}
{{/if}}
</body>
+10 -10
View File
@@ -29,19 +29,21 @@
<div class="card">
<h3>Playlist items</h3>
<table class="table-cards">
<thead><tr><th>Order</th><th>Slide</th><th>Duration</th><th>Schedule</th><th>Actions</th></tr></thead>
<thead><tr><th>Order</th><th>Slide</th><th>Schedule</th><th>Duration</th><th>Actions</th></tr></thead>
<tbody id="playlist-items-body">
{{#if playlistSlides.length}}
{{#each playlistSlides}}
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}">
<td class="playlist-order-cell" data-label="Order">{{displayOrder}}</td>
<td class="playlist-order-cell" data-label="Order">
<div class="playlist-order-cell-inner">
<button type="button" class="playlist-drag-handle" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"></button>
<span class="playlist-order-number">{{displayOrder}}</span>
</div>
</td>
<td data-label="Slide">
{{title}}
<input type="hidden" name="slide_id[]" value="{{slide_id}}" />
</td>
<td data-label="Duration">
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required />
</td>
<td data-label="Schedule">
<div class="playlist-schedule-summary">{{scheduleSummary}}</div>
<input type="hidden" name="schedule_mode[]" value="{{schedule_mode}}" />
@@ -51,10 +53,11 @@
<input type="hidden" name="schedule_end_time[]" value="{{schedule_end_time}}" />
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" />
</td>
<td data-label="Duration">
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required />
</td>
<td data-label="Actions">
<div class="actions playlist-item-actions">
<button type="button" class="secondary" data-playlist-move="up" {{#if isFirst}}disabled{{/if}}>Up</button>
<button type="button" class="secondary" data-playlist-move="down" {{#if isLast}}disabled{{/if}}>Down</button>
<button type="button" class="schedule-config-button" data-schedule-config="/admin/playlists/{{../playlist.id}}/slides/{{id}}/config" data-schedule-config-row="existing-{{id}}">Schedule</button>
<button type="button" class="danger" data-playlist-remove-row>Remove</button>
</div>
@@ -82,9 +85,6 @@
{{/each}}
</select>
</label>
<label style="width: 160px;">Visible for (seconds)
<input id="playlist-add-duration" type="number" min="1" value="10" required />
</label>
</div>
<button type="button" id="playlist-add-slide-button">Add slide</button>
</form>