Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4de31269d | ||
|
|
235d2da6aa | ||
|
|
d24c9c4035 | ||
|
|
04ddec50ad | ||
|
|
47613e61a2 | ||
|
|
bd130a1070 |
@@ -6,6 +6,37 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- No unreleased changes recorded yet.
|
||||
|
||||
## 1.5.9 - 2026-07-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- User creation now keeps the role checkboxes inside the add-user form so selected roles are submitted correctly.
|
||||
|
||||
## 1.5.8 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
|
||||
- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
|
||||
|
||||
## 1.5.7 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
|
||||
|
||||
## 1.5.6 - 2026-07-25
|
||||
|
||||
### Changed
|
||||
|
||||
- Playlist slide picker now uses a button to reveal already added slides, while still defaulting to showing only available slides.
|
||||
- Already-added slides in the picker are now labeled and visually muted to make them easier to spot.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Thumbnails in slide and playlist tables now follow the slide canvas ratio and cap at 5.5rem tall.
|
||||
- Thumbnail-only table columns are no longer sortable.
|
||||
|
||||
## 1.5.5 - 2026-07-25
|
||||
|
||||
### Fixed
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.5",
|
||||
"version": "1.5.9",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
+46
-5
@@ -47,6 +47,19 @@ function canonicalize(value) {
|
||||
return value === undefined ? undefined : value;
|
||||
}
|
||||
|
||||
function normalizeRequestPath(pathValue) {
|
||||
const value = String(pathValue || '').trim();
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
try {
|
||||
return decodeURIComponent(value);
|
||||
} catch (_error) {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
function stableJson(value) {
|
||||
return JSON.stringify(canonicalize(value));
|
||||
}
|
||||
@@ -56,10 +69,38 @@ function hashPayload(value) {
|
||||
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||
}
|
||||
|
||||
function normalizeRequestAuthBody(req) {
|
||||
if (!req) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const body = req.body;
|
||||
if (body === undefined || body === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Buffer.isBuffer(body)) {
|
||||
return body;
|
||||
}
|
||||
|
||||
if (typeof body === 'object' && !Array.isArray(body)) {
|
||||
const hasFields = Object.keys(body).length > 0;
|
||||
if (!hasFields) {
|
||||
const contentLength = Number(String(req.headers && req.headers['content-length'] || '').trim() || 0);
|
||||
const contentType = String(req.headers && req.headers['content-type'] || '').trim();
|
||||
if (!contentLength || !contentType) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
function getRequestPath(req) {
|
||||
const explicitPath = String(req && req.path ? req.path : '').trim();
|
||||
if (explicitPath) {
|
||||
return explicitPath;
|
||||
return normalizeRequestPath(explicitPath);
|
||||
}
|
||||
|
||||
const rawUrl = String(req && req.url ? req.url : '').trim();
|
||||
@@ -68,9 +109,9 @@ function getRequestPath(req) {
|
||||
}
|
||||
|
||||
try {
|
||||
return new URL(rawUrl, 'http://localhost').pathname;
|
||||
return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
|
||||
} catch (_error) {
|
||||
return rawUrl.split('?')[0] || '';
|
||||
return normalizeRequestPath(rawUrl.split('?')[0] || '');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -167,7 +208,7 @@ function createRequestAuthHeaders(options) {
|
||||
}
|
||||
|
||||
const method = String(options && options.method || 'GET').trim().toUpperCase();
|
||||
const pathname = String(options && options.pathname || '').trim();
|
||||
const pathname = normalizeRequestPath(options && options.pathname || '');
|
||||
const timestamp = String(options && options.timestamp || Date.now()).trim();
|
||||
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
|
||||
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
|
||||
@@ -199,7 +240,7 @@ function verifyRequestAuth(req) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(req.body)}`);
|
||||
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(normalizeRequestAuthBody(req))}`);
|
||||
return timingSafeEqualHex(expectedSignature, signature);
|
||||
}
|
||||
|
||||
|
||||
@@ -120,124 +120,6 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
return buildTextRegionMarkup(region, regionContent);
|
||||
}
|
||||
|
||||
function buildSlideHtml(slide, baseUrl) {
|
||||
const canvasSize = getCanvasSize(slide);
|
||||
const template = slide && slide.template ? slide.template : null;
|
||||
const backgroundColor = sanitizeTextColor(template && template.background_color ? template.background_color : '#111111', '#111111');
|
||||
const backgroundImagePath = template && template.background_image_path ? resolveAssetUrl(baseUrl, template.background_image_path) : '';
|
||||
|
||||
if (!template) {
|
||||
const mediaPath = String(slide && slide.media_path || '').trim();
|
||||
const mediaUrl = resolveAssetUrl(baseUrl, mediaPath);
|
||||
const kind = mediaKind(mediaPath || slide.media_type || '');
|
||||
const mediaMarkup = kind === 'image' && mediaUrl
|
||||
? '<img src="' + escapeHtml(mediaUrl) + '" alt="' + escapeHtml(slide.title || 'slide') + '" />'
|
||||
: '';
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html>',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
||||
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
||||
' <style>',
|
||||
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
||||
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
||||
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
||||
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
||||
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
||||
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
||||
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
||||
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
||||
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
||||
' </style>',
|
||||
' </head>',
|
||||
' <body>',
|
||||
' <div class="thumbnail-stage">',
|
||||
mediaMarkup ? '<div class="slide"><div class="slide-canvas slide-media" style="width:' + canvasSize.width + 'px;height:' + canvasSize.height + 'px;">' + mediaMarkup + '</div></div>' : '',
|
||||
' </div>',
|
||||
' </body>',
|
||||
'</html>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
const regions = Array.isArray(template && template.regions) ? template.regions.slice().sort(function (left, right) {
|
||||
return Number(left.z_index || 0) - Number(right.z_index || 0) || Number(left.id || 0) - Number(right.id || 0);
|
||||
}) : [];
|
||||
|
||||
const regionsHtml = regions.map(function (region) {
|
||||
const content = getRegionContent(slide, region);
|
||||
const left = Math.max(0, Number(region.x || 0));
|
||||
const top = Math.max(0, Number(region.y || 0));
|
||||
const width = Math.max(1, Number(region.width || 1));
|
||||
const height = Math.max(1, Number(region.height || 1));
|
||||
const regionType = String(region.region_type || 'text').toLowerCase();
|
||||
const pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
|
||||
const pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
|
||||
const innerHtml = regionType === 'text'
|
||||
? buildTextRegionMarkup(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content)
|
||||
: buildRegionInnerHtml(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content, baseUrl);
|
||||
|
||||
if (!innerHtml) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return [
|
||||
'<div class="template-region ' + escapeHtml(regionType) + '" style="left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';">',
|
||||
innerHtml,
|
||||
'</div>'
|
||||
].join('');
|
||||
}).join('');
|
||||
|
||||
return [
|
||||
'<!doctype html>',
|
||||
'<html>',
|
||||
' <head>',
|
||||
' <meta charset="utf-8" />',
|
||||
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
|
||||
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
|
||||
' <style>',
|
||||
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
|
||||
' body { display: flex; align-items: stretch; justify-content: stretch; }',
|
||||
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
|
||||
' .thumbnail-stage .template-stage { position: relative; width: 100%; height: 100%; }',
|
||||
' .thumbnail-stage .template-stage .template-background { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; z-index: 0; }',
|
||||
' .thumbnail-stage .template-region { position: absolute; overflow: hidden; box-sizing: border-box; }',
|
||||
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
|
||||
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.rtmp { background: #000; }',
|
||||
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
|
||||
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
|
||||
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
|
||||
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
|
||||
' .thumbnail-stage .template-region-placeholder { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; color: rgba(255, 255, 255, 0.65); background: rgba(255, 255, 255, 0.06); font-size: 0.9rem; }',
|
||||
' </style>',
|
||||
' </head>',
|
||||
' <body>',
|
||||
' <div class="thumbnail-stage">',
|
||||
' <div class="template-stage" style="background-color:' + escapeHtml(backgroundColor) + ';">',
|
||||
backgroundImagePath ? ' <img class="template-background" src="' + escapeHtml(backgroundImagePath) + '" alt="" />' : '',
|
||||
regionsHtml,
|
||||
' </div>',
|
||||
' </div>',
|
||||
' </body>',
|
||||
'</html>'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
async function launchBrowser() {
|
||||
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||
return fs.existsSync(candidate);
|
||||
@@ -312,12 +194,13 @@ async function captureSlideThumbnail(options) {
|
||||
const previewUrl = baseUrl + previewPath;
|
||||
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||
method: 'GET',
|
||||
pathname: previewPath,
|
||||
body: {}
|
||||
pathname: previewPath
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForSelector('.slide-canvas', { timeout: 30000, visible: true });
|
||||
await page.waitForFunction(function () {
|
||||
return Boolean(document.querySelector('.slide-canvas'));
|
||||
}, { timeout: 30000 });
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
|
||||
@@ -1265,6 +1265,19 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
box-shadow: none;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-assigned {
|
||||
border-color: rgba(var(--bs-primary-rgb), 0.35);
|
||||
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-media {
|
||||
filter: saturate(0.85) grayscale(0.25);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-title {
|
||||
color: var(--bs-body-secondary);
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1322,6 +1335,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
padding: 0 0.9rem 0.6rem;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-filter-button {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-meta {
|
||||
display: none;
|
||||
}
|
||||
@@ -1335,7 +1352,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
opacity: 0;
|
||||
background: rgba(var(--bs-body-bg-rgb), 0.88);
|
||||
border-radius: 999px;
|
||||
padding: 0.2rem 0.3rem;
|
||||
padding: 0.2rem 0.45rem;
|
||||
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
|
||||
@@ -1345,6 +1362,17 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
|
||||
.playlist-slide-picker-badge {
|
||||
display: none;
|
||||
position: absolute;
|
||||
left: 0.5rem;
|
||||
top: 0.5rem;
|
||||
z-index: 1;
|
||||
font-size: 0.65rem;
|
||||
letter-spacing: 0.02em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-badge {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-empty.is-hidden {
|
||||
@@ -1438,8 +1466,9 @@ table.table > :not(caption) > * > * {
|
||||
|
||||
.playlist-slide-thumb {
|
||||
flex: 0 0 auto;
|
||||
width: 5.5rem;
|
||||
height: 3.5rem;
|
||||
max-width: 5.5rem;
|
||||
max-height: 5.5rem;
|
||||
aspect-ratio: var(--playlist-slide-thumb-aspect-ratio, 16 / 9);
|
||||
border-radius: 0.5rem;
|
||||
overflow: hidden;
|
||||
background: rgba(15, 23, 42, 0.08);
|
||||
@@ -1456,13 +1485,19 @@ table.table > :not(caption) > * > * {
|
||||
}
|
||||
|
||||
.playlist-slide-thumb-placeholder {
|
||||
display: inline-flex;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
min-width: 5.5rem;
|
||||
min-height: 3.5rem;
|
||||
color: var(--bs-secondary-color, #6c757d);
|
||||
font-size: 1.1rem;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%),
|
||||
rgba(15, 23, 42, 0.18);
|
||||
font-size: 2.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.playlist-order-cell {
|
||||
|
||||
@@ -308,6 +308,7 @@
|
||||
var addSlideModal = document.getElementById('playlist-add-slide-modal');
|
||||
var addSlideGrid = document.getElementById('playlist-slide-picker-grid');
|
||||
var addSlideSearch = document.getElementById('playlist-slide-picker-search');
|
||||
var addSlideShowAssigned = document.getElementById('playlist-slide-picker-show-assigned');
|
||||
var addSlideEmpty = document.getElementById('playlist-slide-picker-empty');
|
||||
var addSlideConfirm = document.getElementById('playlist-confirm-add-slides');
|
||||
var addSlideCount = document.getElementById('playlist-slide-picker-selected-count');
|
||||
@@ -408,6 +409,7 @@
|
||||
var media = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
var check = document.createElement('span');
|
||||
var badge = document.createElement('span');
|
||||
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
||||
|
||||
button.type = 'button';
|
||||
@@ -415,6 +417,7 @@
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.setAttribute('data-slide-id', String(slide.id || ''));
|
||||
button.setAttribute('data-search-text', searchText);
|
||||
button.setAttribute('data-is-assigned', slide && slide.isAssigned ? 'true' : 'false');
|
||||
|
||||
if (slide && slide.isAssigned) {
|
||||
button.disabled = true;
|
||||
@@ -427,6 +430,11 @@
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
media.appendChild(check);
|
||||
|
||||
badge.className = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||
badge.textContent = 'Already in playlist';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
media.appendChild(badge);
|
||||
|
||||
title.className = 'playlist-slide-picker-title';
|
||||
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
||||
|
||||
@@ -436,6 +444,16 @@
|
||||
return button;
|
||||
}
|
||||
|
||||
function setShowAssignedState(showAssignedSlides) {
|
||||
if (!addSlideShowAssigned) {
|
||||
return;
|
||||
}
|
||||
|
||||
addSlideShowAssigned.setAttribute('aria-pressed', showAssignedSlides ? 'true' : 'false');
|
||||
addSlideShowAssigned.classList.toggle('btn-secondary', showAssignedSlides);
|
||||
addSlideShowAssigned.classList.toggle('btn-outline-secondary', !showAssignedSlides);
|
||||
}
|
||||
|
||||
function renderSlidePicker() {
|
||||
if (!addSlideGrid) {
|
||||
return;
|
||||
@@ -451,19 +469,31 @@
|
||||
|
||||
function syncSlidePickerState() {
|
||||
var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase();
|
||||
var showAssignedSlides = Boolean(addSlideShowAssigned && addSlideShowAssigned.getAttribute('aria-pressed') === 'true');
|
||||
var visibleCount = 0;
|
||||
var assignedCount = 0;
|
||||
|
||||
slidePickerCards.forEach(function (card) {
|
||||
var searchText = String(card.getAttribute('data-search-text') || '');
|
||||
var matches = !query || searchText.indexOf(query) !== -1;
|
||||
var isAssigned = card.getAttribute('data-is-assigned') === 'true';
|
||||
var matchesSearch = !query || searchText.indexOf(query) !== -1;
|
||||
var matchesAssignment = showAssignedSlides || !isAssigned;
|
||||
var matches = matchesSearch && matchesAssignment;
|
||||
setCardVisible(card, matches);
|
||||
if (isAssigned) {
|
||||
assignedCount += 1;
|
||||
}
|
||||
if (matches) {
|
||||
visibleCount += 1;
|
||||
}
|
||||
});
|
||||
|
||||
if (addSlideEmpty) {
|
||||
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||
addSlideEmpty.textContent = query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||
} else {
|
||||
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||
}
|
||||
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||
}
|
||||
|
||||
@@ -474,6 +504,8 @@
|
||||
if (addSlideConfirm) {
|
||||
addSlideConfirm.disabled = slidePickerSelection.size === 0;
|
||||
}
|
||||
|
||||
setShowAssignedState(showAssignedSlides);
|
||||
}
|
||||
|
||||
function toggleCardSelection(card) {
|
||||
@@ -512,6 +544,8 @@
|
||||
row_key: 'new-' + Date.now() + '-' + slide.id,
|
||||
slide_id: String(slide.id),
|
||||
thumbnail_path: String(slide.thumbnail_path || ''),
|
||||
canvas_width: slide.canvas_width,
|
||||
canvas_height: slide.canvas_height,
|
||||
canvas_signature: String(slide.canvasSignature || ''),
|
||||
title: slide.title || 'Slide',
|
||||
duration_seconds: 10,
|
||||
@@ -626,7 +660,10 @@
|
||||
var isAssigned = Boolean(activeSlideIds[slideId]);
|
||||
var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature);
|
||||
|
||||
slide.isAssigned = isAssigned;
|
||||
|
||||
if (card) {
|
||||
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||
card.disabled = isAssigned || isCanvasMismatch;
|
||||
card.classList.toggle('is-assigned', isAssigned);
|
||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||
@@ -736,6 +773,12 @@
|
||||
var row = document.createElement('tr');
|
||||
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||
var playlistId = tbody.getAttribute('data-playlist-id') || '';
|
||||
var canvasWidth = Number(values.canvas_width);
|
||||
var canvasHeight = Number(values.canvas_height);
|
||||
var thumbnailStyle = '';
|
||||
if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) && canvasWidth > 0 && canvasHeight > 0) {
|
||||
thumbnailStyle = ' style="--playlist-slide-thumb-aspect-ratio: ' + canvasWidth + ' / ' + canvasHeight + ';"';
|
||||
}
|
||||
var thumbnailMarkup = values.thumbnail_path
|
||||
? '<img class="playlist-slide-thumb-image" src="' + values.thumbnail_path + '" alt="" loading="lazy" />'
|
||||
: '<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>';
|
||||
@@ -752,7 +795,7 @@
|
||||
'</td>' +
|
||||
'<td data-label="Slide">' +
|
||||
'<div class="playlist-slide-cell">' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true">' + thumbnailMarkup + '</div>' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
||||
@@ -839,6 +882,14 @@
|
||||
addSlideSearch.addEventListener('input', syncSlidePickerState);
|
||||
}
|
||||
|
||||
if (addSlideShowAssigned) {
|
||||
addSlideShowAssigned.addEventListener('click', function () {
|
||||
var nextState = addSlideShowAssigned.getAttribute('aria-pressed') !== 'true';
|
||||
setShowAssignedState(nextState);
|
||||
syncSlidePickerState();
|
||||
});
|
||||
}
|
||||
|
||||
if (addSlideConfirm) {
|
||||
addSlideConfirm.addEventListener('click', function () {
|
||||
addSelectedSlides();
|
||||
|
||||
@@ -174,26 +174,6 @@
|
||||
};
|
||||
}
|
||||
|
||||
function findFilterLink(target) {
|
||||
if (!target || !target.closest) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var link = target.closest('a[data-background-tasks-nav]');
|
||||
if (!link) {
|
||||
return null;
|
||||
}
|
||||
|
||||
var href = String(link.getAttribute('href') || '').trim();
|
||||
if (!href || href === '#') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
href: href
|
||||
};
|
||||
}
|
||||
|
||||
function initPaginationNavigation() {
|
||||
document.addEventListener('click', function (event) {
|
||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||
@@ -217,24 +197,6 @@
|
||||
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
|
||||
return;
|
||||
}
|
||||
|
||||
var filterLink = findFilterLink(event.target);
|
||||
if (!filterLink) {
|
||||
return;
|
||||
}
|
||||
|
||||
var filterUrl = getPageUrl(filterLink.href);
|
||||
if (!filterUrl) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (filterUrl.pathname === window.location.pathname && filterUrl.search === window.location.search && filterUrl.hash === window.location.hash) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
loadPage(filterUrl.toString(), { sectionNames: ['tasks'] });
|
||||
}, true);
|
||||
|
||||
window.addEventListener('popstate', function () {
|
||||
|
||||
@@ -114,6 +114,10 @@
|
||||
return false;
|
||||
}
|
||||
|
||||
if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -2,7 +2,6 @@ const { renderView } = require('../../view');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
|
||||
const TASKS_PER_PAGE = 10;
|
||||
const TASK_STATUSES = new Set(['queued', 'running', 'failed', 'completed', 'canceled']);
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
@@ -22,11 +21,6 @@ function parsePageNumber(value) {
|
||||
return Math.max(1, pageNumber);
|
||||
}
|
||||
|
||||
function normalizeStatusFilter(value) {
|
||||
const status = String(value || '').trim().toLowerCase();
|
||||
return TASK_STATUSES.has(status) ? status : '';
|
||||
}
|
||||
|
||||
function normalizeSourceFilter(sourceType, sourceId) {
|
||||
const normalizedSourceType = String(sourceType || '').trim();
|
||||
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
||||
@@ -92,11 +86,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState) {
|
||||
};
|
||||
}
|
||||
|
||||
function taskMatchesFilters(task, statusFilter, sourceFilter) {
|
||||
if (statusFilter && task.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
function taskMatchesFilters(task, sourceFilter) {
|
||||
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
||||
return true;
|
||||
}
|
||||
@@ -106,12 +96,26 @@ function taskMatchesFilters(task, statusFilter, sourceFilter) {
|
||||
}
|
||||
|
||||
function formatStatusLabel(status) {
|
||||
const normalizedStatus = String(status || '').trim().toLowerCase();
|
||||
if (!normalizedStatus) {
|
||||
return 'All tasks';
|
||||
const normalized = String(status || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return normalizedStatus.charAt(0).toUpperCase() + normalizedStatus.slice(1);
|
||||
if (normalized === 'queued') {
|
||||
return 'Queued';
|
||||
}
|
||||
if (normalized === 'running') {
|
||||
return 'Running';
|
||||
}
|
||||
if (normalized === 'completed') {
|
||||
return 'Completed';
|
||||
}
|
||||
if (normalized === 'failed') {
|
||||
return 'Failed';
|
||||
}
|
||||
if (normalized === 'canceled') {
|
||||
return 'Canceled';
|
||||
}
|
||||
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||
}
|
||||
|
||||
function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||
@@ -133,17 +137,6 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||
return buildQueryString(nextQuery);
|
||||
}
|
||||
|
||||
function buildStatusFilterUrl(queryState, statusFilter) {
|
||||
const nextQuery = Object.assign({}, queryState, {
|
||||
page: 1,
|
||||
status: statusFilter && statusFilter.status === statusFilter.targetStatus ? '' : statusFilter.targetStatus,
|
||||
sourceType: statusFilter && statusFilter.sourceType ? statusFilter.sourceType : '',
|
||||
sourceId: statusFilter && statusFilter.sourceId ? statusFilter.sourceId : ''
|
||||
});
|
||||
|
||||
return buildQueryString(nextQuery);
|
||||
}
|
||||
|
||||
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
|
||||
const tasks = (data && data.tasks) || [];
|
||||
const recurringTasks = (data && data.recurringTasks) || [];
|
||||
@@ -152,16 +145,14 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
const queryState = {
|
||||
page: parsePageNumber(data && data.page),
|
||||
recurringPage: parsePageNumber(data && data.recurringPage),
|
||||
status: normalizeStatusFilter(data && data.status),
|
||||
sourceType: '',
|
||||
sourceId: ''
|
||||
};
|
||||
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
|
||||
queryState.status = normalizeStatusFilter(data && data.status);
|
||||
queryState.sourceType = sourceFilter.sourceType;
|
||||
queryState.sourceId = sourceFilter.sourceId;
|
||||
const filteredTasks = tasks.filter(function (task) {
|
||||
return taskMatchesFilters(task, queryState.status, sourceFilter);
|
||||
return taskMatchesFilters(task, sourceFilter);
|
||||
});
|
||||
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
|
||||
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
|
||||
@@ -193,18 +184,8 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
variant: 'success'
|
||||
}
|
||||
].map(function (card) {
|
||||
const isActive = queryState.status === card.status;
|
||||
const statusFilter = {
|
||||
status: queryState.status,
|
||||
sourceType: queryState.sourceType,
|
||||
sourceId: queryState.sourceId,
|
||||
targetStatus: card.status
|
||||
};
|
||||
|
||||
return Object.assign({}, card, {
|
||||
active: isActive,
|
||||
href: buildStatusFilterUrl(queryState, statusFilter),
|
||||
ariaLabel: isActive ? `Clear ${card.label.toLowerCase()} filter` : `Filter tasks by ${card.label.toLowerCase()}`
|
||||
active: false
|
||||
});
|
||||
});
|
||||
|
||||
@@ -216,7 +197,7 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
|
||||
});
|
||||
});
|
||||
const hasActiveFilters = Boolean(queryState.status || queryState.sourceType || queryState.sourceId);
|
||||
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
|
||||
|
||||
return renderView('settings/background-tasks/index', {
|
||||
title: 'Background tasks',
|
||||
@@ -233,7 +214,6 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
}),
|
||||
summary: summary,
|
||||
statusCards: statusCards,
|
||||
activeStatusLabel: formatStatusLabel(queryState.status),
|
||||
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
|
||||
hasActiveFilters: hasActiveFilters,
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Background tasks</h2>
|
||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them. Click a status box or source name to filter the results.</p>
|
||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
{{#if hasActiveFilters}}
|
||||
<div class="alert alert-light border d-flex flex-wrap align-items-center justify-content-between gap-2 py-2 mb-3">
|
||||
<div class="small text-muted">
|
||||
Showing results for {{#if activeStatusLabel}}{{activeStatusLabel}}{{else}}all tasks{{/if}}{{#if activeSourceLabel}} from {{activeSourceLabel}}{{/if}}.
|
||||
{{#if activeSourceLabel}}Showing results from {{activeSourceLabel}}.{{/if}}
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{clearFiltersUrl}}" data-background-tasks-nav="clear">Clear filters</a>
|
||||
</div>
|
||||
@@ -21,14 +21,12 @@
|
||||
<div id="background-tasks-summary" class="row g-3 mb-4">
|
||||
{{#each statusCards}}
|
||||
<div class="col-6 col-xl-3">
|
||||
<a href="{{href}}" class="d-block h-100 text-decoration-none text-body" data-background-tasks-nav="status" aria-label="{{ariaLabel}}"{{#if active}} aria-current="page"{{/if}}>
|
||||
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">{{label}}</div>
|
||||
<div class="fs-3 fw-semibold">{{count}}</div>
|
||||
</div>
|
||||
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">{{label}}</div>
|
||||
<div class="fs-3 fw-semibold">{{count}}</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
@@ -5,70 +5,70 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">User details</h3>
|
||||
<form id="user-form" method="post" action="/users">
|
||||
<div class="row g-4">
|
||||
<div class="col-12 col-xl-4">
|
||||
<div class="card card-outline card-primary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">User details</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-username" class="form-label">Username</label>
|
||||
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-name" class="form-label">Name</label>
|
||||
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-password" class="form-label">Password</label>
|
||||
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-confirm-password" class="form-label">Confirm password</label>
|
||||
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="User actions">
|
||||
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<form id="user-form" method="post" action="/users">
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-username" class="form-label">Username</label>
|
||||
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-name" class="form-label">Name</label>
|
||||
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-password" class="form-label">Password</label>
|
||||
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
<div class="col-12 col-md-6">
|
||||
<label for="user-confirm-password" class="form-label">Confirm password</label>
|
||||
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end">
|
||||
<div class="btn-group" role="group" aria-label="User actions">
|
||||
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
|
||||
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-12 col-xl-8">
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">Roles</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row g-3">
|
||||
{{#if roles.length}}
|
||||
{{#each roles}}
|
||||
<div class="col-4">
|
||||
<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="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
{{#if roles.length}}
|
||||
{{#each roles}}
|
||||
<div class="col-4">
|
||||
<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="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
<span class="form-check-label pe-4">
|
||||
<strong>{{name}}</strong>
|
||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="col-12">
|
||||
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
|
||||
</div>
|
||||
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -100,7 +100,7 @@
|
||||
</td>
|
||||
<td data-label="Slide">
|
||||
<div class="playlist-slide-cell">
|
||||
<div class="playlist-slide-thumb" aria-hidden="true">
|
||||
<div class="playlist-slide-thumb" aria-hidden="true" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
|
||||
{{#if thumbnail_path}}
|
||||
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||
{{else}}
|
||||
@@ -153,11 +153,16 @@
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body playlist-slide-picker-modal-body">
|
||||
<div class="playlist-slide-picker-toolbar">
|
||||
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
|
||||
<div class="playlist-slide-picker-toolbar d-flex flex-column flex-lg-row gap-3 align-items-stretch align-items-lg-end">
|
||||
<div class="flex-grow-1">
|
||||
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
|
||||
<div class="input-group">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-slide-picker-filter mb-0">
|
||||
<button type="button" class="btn btn-outline-secondary playlist-slide-picker-filter-button" id="playlist-slide-picker-show-assigned" aria-pressed="false">Show already added slides</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="playlist-slide-picker-empty empty is-hidden" id="playlist-slide-picker-empty">No slides match your search.</div>
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Thumbnail</th>
|
||||
<th data-sortable="false">Thumbnail</th>
|
||||
<th>Title</th>
|
||||
<th>Template</th>
|
||||
<th>Actions</th>
|
||||
@@ -33,7 +33,7 @@
|
||||
{{#each slides}}
|
||||
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
|
||||
<td data-label="Thumbnail">
|
||||
<div class="playlist-slide-thumb" aria-hidden="true">
|
||||
<div class="playlist-slide-thumb" aria-hidden="true" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
|
||||
{{#if thumbnail_path}}
|
||||
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||
{{else}}
|
||||
|
||||
Reference in New Issue
Block a user