Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c5cabbd13 | ||
|
|
815c808d73 |
+18
-7
@@ -6,6 +6,24 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- No unreleased changes recorded yet.
|
- No unreleased changes recorded yet.
|
||||||
|
|
||||||
|
## 1.5.14 - 2026-07-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Dashboard client action icons now render valid Bootstrap Icon classes without duplicating the `bi-` prefix.
|
||||||
|
|
||||||
|
## 1.5.13 - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
|
||||||
|
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
|
||||||
|
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
|
||||||
|
|
||||||
## 1.5.12 - 2026-07-26
|
## 1.5.12 - 2026-07-26
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
@@ -58,13 +76,6 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
### Fixed
|
### 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.
|
- 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
|
## 1.5.6 - 2026-07-25
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.12",
|
"version": "1.5.14",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.12",
|
"version": "1.5.14",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.12",
|
"version": "1.5.14",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ const db = require('./db');
|
|||||||
const data = require('./data');
|
const data = require('./data');
|
||||||
const player = require('./player/render');
|
const player = require('./player/render');
|
||||||
|
|
||||||
|
function getSearchQuery(req) {
|
||||||
|
return String(req && req.query && req.query.search || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortQuery(req) {
|
||||||
|
return String(req && req.query && req.query.sort || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortDirectionQuery(req) {
|
||||||
|
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createPool: db.createPool,
|
createPool: db.createPool,
|
||||||
ensureSchema: db.ensureSchema,
|
ensureSchema: db.ensureSchema,
|
||||||
@@ -14,6 +26,9 @@ module.exports = {
|
|||||||
fetchTemplatesPage: data.fetchTemplatesPage,
|
fetchTemplatesPage: data.fetchTemplatesPage,
|
||||||
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
||||||
fetchScreensPage: data.fetchScreensPage,
|
fetchScreensPage: data.fetchScreensPage,
|
||||||
|
getSearchQuery: getSearchQuery,
|
||||||
|
getSortQuery: getSortQuery,
|
||||||
|
getSortDirectionQuery: getSortDirectionQuery,
|
||||||
fetchPlaylistById: data.fetchPlaylistById,
|
fetchPlaylistById: data.fetchPlaylistById,
|
||||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||||
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||||
|
|||||||
+59
-5
@@ -35,13 +35,23 @@ async function fetchAdminData(pool) {
|
|||||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchPlaylistsPage(pool, page, pageSize) {
|
async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
|
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
|
||||||
(SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
|
(SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
|
||||||
FROM playlists p
|
FROM playlists p
|
||||||
ORDER BY p.id DESC`,
|
ORDER BY p.id DESC`,
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM playlists',
|
countSql: 'SELECT COUNT(*) AS count FROM playlists',
|
||||||
|
searchColumns: ['p.name'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
name: 'p.name',
|
||||||
|
slides: 'slide_count',
|
||||||
|
created: 'p.created_at',
|
||||||
|
modified: 'p.modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
@@ -49,7 +59,7 @@ async function fetchPlaylistsPage(pool, page, pageSize) {
|
|||||||
return Object.assign({ playlists: paged.rows }, paged);
|
return Object.assign({ playlists: paged.rows }, paged);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSlidesPage(pool, page, pageSize) {
|
async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
FROM slides s
|
FROM slides s
|
||||||
@@ -57,6 +67,16 @@ async function fetchSlidesPage(pool, page, pageSize) {
|
|||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY s.id DESC`,
|
ORDER BY s.id DESC`,
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM slides',
|
countSql: 'SELECT COUNT(*) AS count FROM slides',
|
||||||
|
searchColumns: ['s.title', 's.body', 's.media_path', 'st.name', 's.content_json'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
title: 's.title',
|
||||||
|
template: 'st.name',
|
||||||
|
created: 's.created_at',
|
||||||
|
modified: 's.modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
@@ -64,7 +84,7 @@ async function fetchSlidesPage(pool, page, pageSize) {
|
|||||||
return Object.assign({ slides: paged.rows }, paged);
|
return Object.assign({ slides: paged.rows }, paged);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchTemplatesPage(pool, page, pageSize) {
|
async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
||||||
@@ -74,6 +94,18 @@ async function fetchTemplatesPage(pool, page, pageSize) {
|
|||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY st.id DESC`,
|
ORDER BY st.id DESC`,
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
|
countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
|
||||||
|
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
name: 'st.name',
|
||||||
|
canvas: ['cs.width', 'cs.height', 'cs.name'],
|
||||||
|
regions: 'region_count',
|
||||||
|
slides: 'slide_count',
|
||||||
|
created: 'st.created_at',
|
||||||
|
modified: 'st.modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
@@ -81,13 +113,24 @@ async function fetchTemplatesPage(pool, page, pageSize) {
|
|||||||
return Object.assign({ templates: paged.rows }, paged);
|
return Object.assign({ templates: paged.rows }, paged);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCanvasSizesPage(pool, page, pageSize) {
|
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
|
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
|
||||||
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
|
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
|
||||||
FROM canvas_sizes
|
FROM canvas_sizes
|
||||||
ORDER BY width ASC, height ASC, name ASC`,
|
ORDER BY width ASC, height ASC, name ASC`,
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||||
|
searchColumns: ['name', 'width', 'height'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
name: 'name',
|
||||||
|
dimensions: ['width', 'height', 'name'],
|
||||||
|
templates: 'template_count',
|
||||||
|
created: 'created_at',
|
||||||
|
modified: 'modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
@@ -95,13 +138,24 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
|
|||||||
return Object.assign({ canvasSizes: paged.rows }, paged);
|
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchScreensPage(pool, page, pageSize) {
|
async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
|
||||||
FROM screens s
|
FROM screens s
|
||||||
LEFT JOIN playlists p ON p.id = s.playlist_id
|
LEFT JOIN playlists p ON p.id = s.playlist_id
|
||||||
ORDER BY s.id DESC`,
|
ORDER BY s.id DESC`,
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM screens',
|
countSql: 'SELECT COUNT(*) AS count FROM screens',
|
||||||
|
searchColumns: ['s.name', 's.slug', 'p.name'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
name: 's.name',
|
||||||
|
url: 's.slug',
|
||||||
|
playlist: 'p.name',
|
||||||
|
created: 's.created_at',
|
||||||
|
modified: 's.modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
|
|||||||
+14
-1
@@ -15,10 +15,23 @@ async function fetchApiSourcesData(pool) {
|
|||||||
return { apiSources: apiSources };
|
return { apiSources: apiSources };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchApiSourcesPage(pool, page, pageSize) {
|
async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC',
|
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC',
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM api_sources',
|
countSql: 'SELECT COUNT(*) AS count FROM api_sources',
|
||||||
|
searchColumns: ['name', 'api_url', 'last_pull_error'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
name: 'name',
|
||||||
|
url: 'api_url',
|
||||||
|
interval: ['update_interval_value', 'update_interval_unit'],
|
||||||
|
last_pulled: 'last_pulled_at',
|
||||||
|
last_response: 'last_response_status',
|
||||||
|
created: 'created_at',
|
||||||
|
modified: 'modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -5,10 +5,20 @@ async function fetchCanvasSizesData(pool) {
|
|||||||
return { canvasSizes };
|
return { canvasSizes };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchCanvasSizesPage(pool, page, pageSize) {
|
async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
|
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
|
||||||
|
searchColumns: ['name', 'width', 'height'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
name: 'name',
|
||||||
|
dimensions: ['width', 'height', 'name'],
|
||||||
|
created: 'created_at',
|
||||||
|
modified: 'modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
|
|||||||
+13
-1
@@ -15,10 +15,22 @@ async function fetchRssFeedsData(pool) {
|
|||||||
return { rssFeeds: rssFeeds };
|
return { rssFeeds: rssFeeds };
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRssFeedsPage(pool, page, pageSize) {
|
async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
|
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
|
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
|
||||||
|
searchColumns: ['name', 'feed_url'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
name: 'name',
|
||||||
|
url: 'feed_url',
|
||||||
|
interval: ['update_interval_value', 'update_interval_unit'],
|
||||||
|
items: 'item_limit',
|
||||||
|
created: 'created_at',
|
||||||
|
modified: 'modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
|
|||||||
+151
-2
@@ -27,23 +27,168 @@ function normalizePageNumber(value) {
|
|||||||
return Math.max(1, pageNumber);
|
return Math.max(1, pageNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function escapeLikeValue(value) {
|
||||||
|
return String(value || '').replace(/[\\%_]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortDirection(value) {
|
||||||
|
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
|
||||||
|
const normalizedSortKey = String(sortKey || '').trim();
|
||||||
|
const normalizedSortDirection = normalizeSortDirection(sortDirection);
|
||||||
|
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
|
||||||
|
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
|
||||||
|
|
||||||
|
if (!sortExpression) {
|
||||||
|
return {
|
||||||
|
clause: '',
|
||||||
|
sortKey: normalizedSortKey,
|
||||||
|
sortDirection: normalizedSortDirection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
|
||||||
|
const orderBySql = expressions.map(function (expression) {
|
||||||
|
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
clause: ` ORDER BY ${orderBySql}`,
|
||||||
|
sortKey: normalizedSortKey,
|
||||||
|
sortDirection: normalizedSortDirection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTopLevelOrderByIndex(sql) {
|
||||||
|
const text = String(sql || '');
|
||||||
|
let depth = 0;
|
||||||
|
let inSingleQuote = false;
|
||||||
|
let inDoubleQuote = false;
|
||||||
|
let inBacktick = false;
|
||||||
|
let lastOrderByIndex = -1;
|
||||||
|
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const character = text[index];
|
||||||
|
const previousCharacter = index > 0 ? text[index - 1] : '';
|
||||||
|
|
||||||
|
if (inSingleQuote) {
|
||||||
|
if (character === '\'' && previousCharacter !== '\\') {
|
||||||
|
inSingleQuote = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inDoubleQuote) {
|
||||||
|
if (character === '"' && previousCharacter !== '\\') {
|
||||||
|
inDoubleQuote = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inBacktick) {
|
||||||
|
if (character === '`') {
|
||||||
|
inBacktick = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '\'') {
|
||||||
|
inSingleQuote = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '"') {
|
||||||
|
inDoubleQuote = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '`') {
|
||||||
|
inBacktick = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '(') {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === ')' && depth > 0) {
|
||||||
|
depth -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth === 0 && /[oO]/.test(character)) {
|
||||||
|
const remaining = text.slice(index);
|
||||||
|
if (/^order\s+by\b/i.test(remaining)) {
|
||||||
|
lastOrderByIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastOrderByIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchFilter(searchColumns, searchTerm) {
|
||||||
|
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
|
||||||
|
return String(column || '').trim();
|
||||||
|
}).filter(Boolean) : [];
|
||||||
|
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!columns.length || !normalizedSearchTerm) {
|
||||||
|
return {
|
||||||
|
clause: '',
|
||||||
|
params: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
|
||||||
|
return {
|
||||||
|
clause: ` WHERE (${columns.map(function (column) {
|
||||||
|
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
|
||||||
|
}).join(' OR ')})`,
|
||||||
|
params: columns.map(function () {
|
||||||
|
return likeValue;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchPagedRows(pool, options) {
|
async function fetchPagedRows(pool, options) {
|
||||||
const selectSql = String(options && options.selectSql || '').trim();
|
const selectSql = String(options && options.selectSql || '').trim();
|
||||||
const countSql = String(options && options.countSql || '').trim();
|
const countSql = String(options && options.countSql || '').trim();
|
||||||
const params = Array.isArray(options && options.params) ? options.params : [];
|
const params = Array.isArray(options && options.params) ? options.params : [];
|
||||||
|
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
|
||||||
|
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
|
||||||
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
|
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
|
||||||
const currentPage = normalizePageNumber(options && options.page);
|
const currentPage = normalizePageNumber(options && options.page);
|
||||||
|
|
||||||
if (!selectSql || !countSql) {
|
if (!selectSql || !countSql) {
|
||||||
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||||
}
|
}
|
||||||
|
const orderByIndex = findTopLevelOrderByIndex(selectSql);
|
||||||
|
let baseSelectSql = selectSql;
|
||||||
|
let orderBySql = '';
|
||||||
|
|
||||||
const [countRows] = await pool.query(countSql, params);
|
if (orderByIndex >= 0) {
|
||||||
|
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
|
||||||
|
orderBySql = selectSql.slice(orderByIndex).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSelectSql = `${baseSelectSql}${searchFilter.clause}`;
|
||||||
|
const countQuery = searchFilter.clause
|
||||||
|
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
|
||||||
|
: countSql;
|
||||||
|
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
|
||||||
|
const [countRows] = await pool.query(countQuery, countParams);
|
||||||
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
|
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
|
||||||
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
const safeCurrentPage = Math.min(currentPage, totalPages);
|
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||||
const offset = (safeCurrentPage - 1) * pageSize;
|
const offset = (safeCurrentPage - 1) * pageSize;
|
||||||
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
|
|
||||||
|
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
|
||||||
|
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
|
||||||
|
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
rows: rows || [],
|
rows: rows || [],
|
||||||
@@ -77,6 +222,10 @@ module.exports = {
|
|||||||
parseJsonSafe,
|
parseJsonSafe,
|
||||||
readFormArray,
|
readFormArray,
|
||||||
normalizePageNumber,
|
normalizePageNumber,
|
||||||
|
normalizeSortDirection,
|
||||||
|
buildSortOrderClause,
|
||||||
|
findTopLevelOrderByIndex,
|
||||||
|
buildSearchFilter,
|
||||||
fetchPagedRows,
|
fetchPagedRows,
|
||||||
fetchDuplicateName
|
fetchDuplicateName
|
||||||
};
|
};
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 19 KiB |
@@ -320,7 +320,12 @@ function handleCommandMessage(rawMessage) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case 'pause':
|
case 'pause':
|
||||||
|
var desiredPause = normalizeBoolean(payload.paused);
|
||||||
|
if (desiredPause !== null) {
|
||||||
|
setPaused(desiredPause);
|
||||||
|
} else {
|
||||||
setPaused(!isPaused);
|
setPaused(!isPaused);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case 'blackout':
|
case 'blackout':
|
||||||
var desiredBlackout = normalizeBoolean(payload.blackout);
|
var desiredBlackout = normalizeBoolean(payload.blackout);
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareScreenNames(left, right) {
|
||||||
|
const leftName = String(left && left.name || '').trim();
|
||||||
|
const rightName = String(right && right.name || '').trim();
|
||||||
|
const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
|
||||||
|
if (nameCompare !== 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
function createDashboardStateService(options) {
|
function createDashboardStateService(options) {
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
@@ -82,11 +94,13 @@ function createDashboardStateService(options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
|
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
|
||||||
|
.map(function (screen) {
|
||||||
return Object.assign({}, screen, {
|
return Object.assign({}, screen, {
|
||||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||||
});
|
});
|
||||||
});
|
})
|
||||||
|
.sort(compareScreenNames);
|
||||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||||
return socket && socket.readyState === WebSocket.OPEN;
|
return socket && socket.readyState === WebSocket.OPEN;
|
||||||
|
|||||||
@@ -0,0 +1,89 @@
|
|||||||
|
function parsePageNumber(value) {
|
||||||
|
const pageNumber = Math.floor(Number(value) || 1);
|
||||||
|
return Math.max(1, pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortDirection(value) {
|
||||||
|
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getComparableSortValue(rawValue) {
|
||||||
|
const value = String(rawValue || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return { type: 'empty', value: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const numericValue = Number(value.replace(/,/g, ''));
|
||||||
|
if (!Number.isNaN(numericValue)) {
|
||||||
|
return { type: 'number', value: numericValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
const dateValue = Date.parse(value);
|
||||||
|
if (!Number.isNaN(dateValue)) {
|
||||||
|
return { type: 'date', value: dateValue };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { type: 'string', value: value.toLowerCase() };
|
||||||
|
}
|
||||||
|
|
||||||
|
function compareSortValues(leftValue, rightValue) {
|
||||||
|
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
if (leftValue.type === 'empty') {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
if (rightValue.type === 'empty') {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (leftValue.type === rightValue.type) {
|
||||||
|
if (leftValue.value < rightValue.value) {
|
||||||
|
return -1;
|
||||||
|
}
|
||||||
|
if (leftValue.value > rightValue.value) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortRows(rows, accessor, sortDirection) {
|
||||||
|
const multiplier = normalizeSortDirection(sortDirection) === 'desc' ? -1 : 1;
|
||||||
|
return (Array.isArray(rows) ? rows.slice() : []).sort(function (leftRow, rightRow) {
|
||||||
|
return compareSortValues(getComparableSortValue(accessor(leftRow)), getComparableSortValue(accessor(rightRow))) * multiplier;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSearchMatcher(searchTerm, fields) {
|
||||||
|
const query = String(searchTerm || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
return function () {
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedFields = Array.isArray(fields) ? fields : [];
|
||||||
|
return function (item) {
|
||||||
|
const haystack = normalizedFields.map(function (field) {
|
||||||
|
if (typeof field === 'function') {
|
||||||
|
return field(item);
|
||||||
|
}
|
||||||
|
return item && field ? item[field] : '';
|
||||||
|
}).map(function (value) {
|
||||||
|
return String(value || '').trim().toLowerCase();
|
||||||
|
}).join(' ');
|
||||||
|
|
||||||
|
return haystack.indexOf(query) !== -1;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
parsePageNumber: parsePageNumber,
|
||||||
|
normalizeSortDirection: normalizeSortDirection,
|
||||||
|
getComparableSortValue: getComparableSortValue,
|
||||||
|
compareSortValues: compareSortValues,
|
||||||
|
sortRows: sortRows,
|
||||||
|
createSearchMatcher: createSearchMatcher
|
||||||
|
};
|
||||||
+55
-14
@@ -17,26 +17,61 @@ function buildQueryString(query) {
|
|||||||
return queryString ? `?${queryString}` : '';
|
return queryString ? `?${queryString}` : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
|
function createPageEntry(pageNumber, currentPage, pageParam, queryState) {
|
||||||
const pages = [];
|
|
||||||
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
|
|
||||||
let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2));
|
|
||||||
let endPage = startPage + visiblePageCount - 1;
|
|
||||||
|
|
||||||
if (endPage > totalPages) {
|
|
||||||
endPage = totalPages;
|
|
||||||
startPage = Math.max(1, endPage - visiblePageCount + 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
|
|
||||||
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
|
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
|
||||||
pages.push({
|
|
||||||
|
return {
|
||||||
number: pageNumber,
|
number: pageNumber,
|
||||||
active: pageNumber === currentPage,
|
active: pageNumber === currentPage,
|
||||||
url: buildQueryString(nextQuery)
|
url: buildQueryString(nextQuery)
|
||||||
});
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEllipsisEntry() {
|
||||||
|
return {
|
||||||
|
ellipsis: true
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
|
||||||
|
const pages = [];
|
||||||
|
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
|
||||||
|
const firstPage = 1;
|
||||||
|
const lastPage = totalPages;
|
||||||
|
|
||||||
|
if (totalPages <= visiblePageCount) {
|
||||||
|
for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) {
|
||||||
|
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return pages;
|
||||||
|
}
|
||||||
|
|
||||||
|
const innerPageCount = Math.max(1, visiblePageCount - 2);
|
||||||
|
let startPage = Math.max(2, currentPage - Math.floor(innerPageCount / 2));
|
||||||
|
let endPage = startPage + innerPageCount - 1;
|
||||||
|
|
||||||
|
if (endPage > lastPage - 1) {
|
||||||
|
endPage = lastPage - 1;
|
||||||
|
startPage = Math.max(2, endPage - innerPageCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(createPageEntry(firstPage, currentPage, pageParam, queryState));
|
||||||
|
|
||||||
|
if (startPage > 2) {
|
||||||
|
pages.push(createEllipsisEntry());
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
|
||||||
|
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (endPage < lastPage - 1) {
|
||||||
|
pages.push(createEllipsisEntry());
|
||||||
|
}
|
||||||
|
|
||||||
|
pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState));
|
||||||
|
|
||||||
return pages;
|
return pages;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -51,6 +86,8 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
|||||||
|
|
||||||
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||||
|
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
|
||||||
|
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPage: safeCurrentPage,
|
currentPage: safeCurrentPage,
|
||||||
@@ -59,8 +96,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
|||||||
hasMultiplePages: totalPages > 1,
|
hasMultiplePages: totalPages > 1,
|
||||||
startItem: startIndex + 1,
|
startItem: startIndex + 1,
|
||||||
endItem: endIndex,
|
endItem: endIndex,
|
||||||
|
hasFirst: safeCurrentPage > 1,
|
||||||
hasPrevious: safeCurrentPage > 1,
|
hasPrevious: safeCurrentPage > 1,
|
||||||
hasNext: safeCurrentPage < totalPages,
|
hasNext: safeCurrentPage < totalPages,
|
||||||
|
hasLast: safeCurrentPage < totalPages,
|
||||||
|
firstUrl: buildQueryString(firstQuery),
|
||||||
|
lastUrl: buildQueryString(lastQuery),
|
||||||
previousUrl: buildQueryString(previousQuery),
|
previousUrl: buildQueryString(previousQuery),
|
||||||
nextUrl: buildQueryString(nextQuery),
|
nextUrl: buildQueryString(nextQuery),
|
||||||
pages: pages,
|
pages: pages,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ async function fetchRoles(pool) {
|
|||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchRolesPage(pool, page, pageSize) {
|
async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||||
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
|
||||||
@@ -36,6 +36,18 @@ async function fetchRolesPage(pool, page, pageSize) {
|
|||||||
FROM roles r
|
FROM roles r
|
||||||
ORDER BY r.name ASC`,
|
ORDER BY r.name ASC`,
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM roles',
|
countSql: 'SELECT COUNT(*) AS count FROM roles',
|
||||||
|
searchColumns: ['r.role_key', 'r.name', 'r.description'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
role: 'r.name',
|
||||||
|
description: 'r.description',
|
||||||
|
users: 'user_count',
|
||||||
|
permissions: 'permission_count',
|
||||||
|
created: 'r.created_at',
|
||||||
|
modified: 'r.modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
@@ -122,7 +134,7 @@ async function fetchUsersWithRoles(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||||
COALESCE(role_data.role_names, '') AS role_names,
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
@@ -138,6 +150,17 @@ async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
|||||||
) role_data ON role_data.user_id = u.id
|
) role_data ON role_data.user_id = u.id
|
||||||
ORDER BY u.id ASC`,
|
ORDER BY u.id ASC`,
|
||||||
countSql: 'SELECT COUNT(*) AS count FROM users',
|
countSql: 'SELECT COUNT(*) AS count FROM users',
|
||||||
|
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
user: 'u.username',
|
||||||
|
name: 'u.name',
|
||||||
|
roles: 'role_names',
|
||||||
|
created: 'u.created_at',
|
||||||
|
modified: 'u.modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
page: page,
|
page: page,
|
||||||
pageSize: pageSize
|
pageSize: pageSize
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -267,7 +267,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-actions-card .card-header,
|
.dashboard-actions-card .card-header,
|
||||||
.dashboard-table-card .card-header {
|
.dashboard-screen-card-shell .card-header {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -309,6 +309,107 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-card-header .dashboard-card-heading {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-tile {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.95rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: calc(var(--bs-border-radius) + 0.25rem);
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-tile-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-tile-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-pill.is-live {
|
||||||
|
background: rgba(25, 135, 84, 0.12);
|
||||||
|
color: var(--bs-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-pill.is-idle {
|
||||||
|
background: rgba(108, 117, 125, 0.12);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta dt {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta dd {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 19 KiB |
@@ -164,9 +164,6 @@
|
|||||||
if (typeof window.initTableSearches === 'function') {
|
if (typeof window.initTableSearches === 'function') {
|
||||||
window.initTableSearches(targetElement);
|
window.initTableSearches(targetElement);
|
||||||
}
|
}
|
||||||
if (typeof window.initTablePaginations === 'function') {
|
|
||||||
window.initTablePaginations(targetElement);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||||
@@ -201,7 +198,7 @@
|
|||||||
|
|
||||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||||
}).catch(function (_error) {
|
}).catch(function () {
|
||||||
// Ignore refresh replacement failures and leave the existing content in place.
|
// Ignore refresh replacement failures and leave the existing content in place.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -515,303 +512,10 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function initTablePaginations(root) {
|
|
||||||
var scope = root && root.querySelectorAll ? root : document;
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
|
|
||||||
if (container.getAttribute('data-table-pagination-bound') === 'true') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var table = container.querySelector('[data-table-searchable]');
|
|
||||||
var cardHeader = container.querySelector('.card-header');
|
|
||||||
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
|
|
||||||
var footer = container.querySelector('[data-table-pagination-controls]');
|
|
||||||
var footerSummary = null;
|
|
||||||
var footerList = null;
|
|
||||||
var syncFrame = 0;
|
|
||||||
var state = {
|
|
||||||
currentPage: 1,
|
|
||||||
pageSize: 1,
|
|
||||||
totalPages: 1
|
|
||||||
};
|
|
||||||
|
|
||||||
if (!table || !cardBody || !table.tBodies.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.setAttribute('data-table-pagination-bound', 'true');
|
|
||||||
|
|
||||||
function getRows() {
|
|
||||||
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSearchMatched(row) {
|
|
||||||
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureFooter() {
|
|
||||||
if (footer) {
|
|
||||||
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
|
||||||
footerList = footer.querySelector('[data-table-pagination-list]');
|
|
||||||
return footer;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer = document.createElement('div');
|
|
||||||
footer.className = 'card-footer d-none';
|
|
||||||
footer.setAttribute('data-table-pagination-controls', 'true');
|
|
||||||
footer.innerHTML = '' +
|
|
||||||
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
|
|
||||||
'<div class="text-muted small" data-table-pagination-summary></div>' +
|
|
||||||
'<nav aria-label="Table pages">' +
|
|
||||||
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
|
|
||||||
'</nav>' +
|
|
||||||
'</div>';
|
|
||||||
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
|
|
||||||
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
|
||||||
footerList = footer.querySelector('[data-table-pagination-list]');
|
|
||||||
return footer;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setRowVisible(row, visible) {
|
|
||||||
row.hidden = !visible;
|
|
||||||
row.classList.toggle('d-none', !visible);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getAvailableHeight() {
|
|
||||||
var containerRect = container.getBoundingClientRect();
|
|
||||||
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
|
|
||||||
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
|
|
||||||
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderFooter(totalItems) {
|
|
||||||
var pageButtons = [];
|
|
||||||
|
|
||||||
ensureFooter();
|
|
||||||
|
|
||||||
if (state.totalPages <= 1 || totalItems <= 0) {
|
|
||||||
footer.classList.add('d-none');
|
|
||||||
footerSummary.textContent = '';
|
|
||||||
footerList.innerHTML = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
footer.classList.remove('d-none');
|
|
||||||
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
|
|
||||||
|
|
||||||
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
|
|
||||||
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
|
|
||||||
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
|
|
||||||
}
|
|
||||||
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
|
|
||||||
footerList.innerHTML = pageButtons.join('');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPagination(resetPage) {
|
|
||||||
var rows = getRows();
|
|
||||||
var matchedRows = rows.filter(isSearchMatched);
|
|
||||||
var totalItems = matchedRows.length;
|
|
||||||
var rowHeight = 48;
|
|
||||||
var pageSize;
|
|
||||||
var startIndex;
|
|
||||||
var endIndex;
|
|
||||||
|
|
||||||
if (resetPage) {
|
|
||||||
state.currentPage = 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
rows.forEach(function (row) {
|
|
||||||
if (!isSearchMatched(row)) {
|
|
||||||
setRowVisible(row, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!totalItems) {
|
|
||||||
state.pageSize = 1;
|
|
||||||
state.totalPages = 1;
|
|
||||||
state.currentPage = 1;
|
|
||||||
renderFooter(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
matchedRows.forEach(function (row) {
|
|
||||||
setRowVisible(row, true);
|
|
||||||
});
|
|
||||||
|
|
||||||
if (matchedRows[0]) {
|
|
||||||
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
|
|
||||||
}
|
|
||||||
|
|
||||||
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
|
|
||||||
|
|
||||||
if (pageSize >= totalItems) {
|
|
||||||
state.pageSize = totalItems;
|
|
||||||
state.totalPages = 1;
|
|
||||||
state.currentPage = 1;
|
|
||||||
matchedRows.forEach(function (row) {
|
|
||||||
setRowVisible(row, true);
|
|
||||||
});
|
|
||||||
renderFooter(totalItems);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
state.pageSize = pageSize;
|
|
||||||
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
|
||||||
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
|
|
||||||
startIndex = (state.currentPage - 1) * pageSize;
|
|
||||||
endIndex = startIndex + pageSize;
|
|
||||||
|
|
||||||
matchedRows.forEach(function (row, index) {
|
|
||||||
setRowVisible(row, index >= startIndex && index < endIndex);
|
|
||||||
});
|
|
||||||
|
|
||||||
renderFooter(totalItems);
|
|
||||||
}
|
|
||||||
|
|
||||||
function scheduleSync(options) {
|
|
||||||
if (syncFrame) {
|
|
||||||
window.cancelAnimationFrame(syncFrame);
|
|
||||||
}
|
|
||||||
|
|
||||||
syncFrame = window.requestAnimationFrame(function () {
|
|
||||||
syncFrame = 0;
|
|
||||||
applyPagination(Boolean(options && options.resetPage));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureFooter();
|
|
||||||
|
|
||||||
if (footer) {
|
|
||||||
footer.addEventListener('click', function (event) {
|
|
||||||
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
|
|
||||||
var pageValue;
|
|
||||||
|
|
||||||
if (!target || target.closest('.disabled')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
|
|
||||||
state.currentPage = pageValue;
|
|
||||||
scheduleSync();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('resize', function () {
|
|
||||||
scheduleSync();
|
|
||||||
});
|
|
||||||
|
|
||||||
if (typeof ResizeObserver === 'function') {
|
|
||||||
try {
|
|
||||||
new ResizeObserver(function () {
|
|
||||||
scheduleSync();
|
|
||||||
}).observe(container);
|
|
||||||
} catch (_error) {
|
|
||||||
// Ignore observer setup failures and rely on window resize.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
container.syncTablePagination = scheduleSync;
|
|
||||||
scheduleSync({ resetPage: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initTableSearches(root) {
|
function initTableSearches(root) {
|
||||||
var scope = root && root.querySelectorAll ? root : document;
|
if (typeof window.initTableSearches === 'function') {
|
||||||
|
window.initTableSearches(root);
|
||||||
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
|
|
||||||
if (input.getAttribute('data-table-search-bound') === 'true') {
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var container = input.closest('[data-table-search-container]') || input.closest('.card') || document;
|
|
||||||
var table = container.querySelector('[data-table-searchable]');
|
|
||||||
var emptyRow = null;
|
|
||||||
|
|
||||||
if (!table) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
input.setAttribute('data-table-search-bound', 'true');
|
|
||||||
|
|
||||||
function getSearchableRows() {
|
|
||||||
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getDefaultEmptyRow() {
|
|
||||||
return table.querySelector('tbody tr[data-table-search-empty-default]');
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeGeneratedEmptyRow() {
|
|
||||||
if (emptyRow && emptyRow.parentNode) {
|
|
||||||
emptyRow.parentNode.removeChild(emptyRow);
|
|
||||||
}
|
|
||||||
emptyRow = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureGeneratedEmptyRow(message) {
|
|
||||||
var tbody = table.tBodies[0] || table.querySelector('tbody');
|
|
||||||
var columnCount = 1;
|
|
||||||
|
|
||||||
if (!tbody) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!emptyRow) {
|
|
||||||
emptyRow = document.createElement('tr');
|
|
||||||
emptyRow.setAttribute('data-table-search-empty-row', 'true');
|
|
||||||
var cell = document.createElement('td');
|
|
||||||
cell.className = 'empty';
|
|
||||||
cell.setAttribute('data-table-search-empty-cell', 'true');
|
|
||||||
emptyRow.appendChild(cell);
|
|
||||||
}
|
|
||||||
|
|
||||||
columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1);
|
|
||||||
emptyRow.firstElementChild.colSpan = Math.max(1, columnCount);
|
|
||||||
emptyRow.firstElementChild.textContent = message;
|
|
||||||
|
|
||||||
if (!emptyRow.parentNode) {
|
|
||||||
tbody.appendChild(emptyRow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncSearch() {
|
|
||||||
var query = String(input.value || '').trim().toLowerCase();
|
|
||||||
var rows = getSearchableRows();
|
|
||||||
var visibleCount = 0;
|
|
||||||
var defaultEmptyRow = getDefaultEmptyRow();
|
|
||||||
|
|
||||||
removeGeneratedEmptyRow();
|
|
||||||
|
|
||||||
rows.forEach(function (row) {
|
|
||||||
var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase();
|
|
||||||
var visible = !query || haystack.indexOf(query) !== -1;
|
|
||||||
row.setAttribute('data-table-search-match', visible ? 'true' : 'false');
|
|
||||||
row.classList.toggle('d-none', !visible);
|
|
||||||
row.hidden = !visible;
|
|
||||||
if (visible) {
|
|
||||||
visibleCount += 1;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (defaultEmptyRow) {
|
|
||||||
defaultEmptyRow.classList.toggle('d-none', Boolean(query));
|
|
||||||
defaultEmptyRow.hidden = Boolean(query);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (query && visibleCount === 0) {
|
|
||||||
ensureGeneratedEmptyRow('No results match your search.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window.syncTablePagination === 'function') {
|
|
||||||
window.syncTablePagination(container, { resetPage: true });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
input.addEventListener('input', syncSearch);
|
|
||||||
syncSearch();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSlideThumbPlaceholder() {
|
function createSlideThumbPlaceholder() {
|
||||||
@@ -841,10 +545,6 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.initSortableTables) {
|
|
||||||
window.initSortableTables();
|
|
||||||
}
|
|
||||||
|
|
||||||
initConfirmForms();
|
initConfirmForms();
|
||||||
initDirtyTracking();
|
initDirtyTracking();
|
||||||
initCancelConfirm();
|
initCancelConfirm();
|
||||||
@@ -854,19 +554,9 @@
|
|||||||
initJsonTogglePanels();
|
initJsonTogglePanels();
|
||||||
initLocalDateTimes();
|
initLocalDateTimes();
|
||||||
initTableSearches();
|
initTableSearches();
|
||||||
initTablePaginations();
|
|
||||||
attachSlideThumbFallbacks(document);
|
attachSlideThumbFallbacks(document);
|
||||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||||
window.initLocalDateTimes = initLocalDateTimes;
|
window.initLocalDateTimes = initLocalDateTimes;
|
||||||
window.initTableSearches = initTableSearches;
|
|
||||||
window.initTablePaginations = initTablePaginations;
|
|
||||||
window.syncTablePagination = function (container, options) {
|
|
||||||
if (!container || !container.syncTablePagination) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
container.syncTablePagination(options);
|
|
||||||
};
|
|
||||||
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
|
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
|
||||||
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
|
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -1,111 +0,0 @@
|
|||||||
(function () {
|
|
||||||
function getGroupCheckboxes(group) {
|
|
||||||
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getPermissionKey(checkbox) {
|
|
||||||
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getActionKey(checkbox) {
|
|
||||||
var permissionKey = getPermissionKey(checkbox);
|
|
||||||
var parts = permissionKey.split('.');
|
|
||||||
if (parts.length !== 2) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
return String(parts[1] || '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncPermissionGroup(group) {
|
|
||||||
var checkboxes = getGroupCheckboxes(group);
|
|
||||||
if (!checkboxes.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var readCheckbox = null;
|
|
||||||
var nonReadChecked = false;
|
|
||||||
|
|
||||||
checkboxes.forEach(function (checkbox) {
|
|
||||||
if (getActionKey(checkbox) === 'read') {
|
|
||||||
readCheckbox = checkbox;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (checkbox.checked) {
|
|
||||||
nonReadChecked = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!readCheckbox) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!readCheckbox.checked && nonReadChecked) {
|
|
||||||
readCheckbox.checked = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!readCheckbox.checked) {
|
|
||||||
checkboxes.forEach(function (checkbox) {
|
|
||||||
if (getActionKey(checkbox) !== 'read') {
|
|
||||||
checkbox.checked = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function handleGroupChange(event) {
|
|
||||||
var checkbox = event.target && event.target.matches ? event.target : null;
|
|
||||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var actionKey = getActionKey(checkbox);
|
|
||||||
var group = checkbox.closest('.accordion-item');
|
|
||||||
if (!group) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var checkboxes = getGroupCheckboxes(group);
|
|
||||||
var readCheckbox = checkboxes.find(function (candidate) {
|
|
||||||
return getActionKey(candidate) === 'read';
|
|
||||||
}) || null;
|
|
||||||
|
|
||||||
if (!readCheckbox) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actionKey === 'read' && !checkbox.checked) {
|
|
||||||
checkboxes.forEach(function (candidate) {
|
|
||||||
if (candidate !== checkbox) {
|
|
||||||
candidate.checked = false;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actionKey !== 'read' && checkbox.checked) {
|
|
||||||
readCheckbox.checked = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPermissionGroups() {
|
|
||||||
document.querySelectorAll('.accordion-item').forEach(function (group) {
|
|
||||||
syncPermissionGroup(group);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('change', function (event) {
|
|
||||||
var checkbox = event.target;
|
|
||||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handleGroupChange(event);
|
|
||||||
syncPermissionGroup(checkbox.closest('.accordion-item'));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
initPermissionGroups();
|
|
||||||
}());
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
(function () {
|
|
||||||
function updateSidebarStatus(status, label) {
|
|
||||||
var dot = document.getElementById('sidebar-status-dot');
|
|
||||||
var text = document.getElementById('sidebar-status-text');
|
|
||||||
var pill = document.getElementById('sidebar-status-pill');
|
|
||||||
if (!dot && !text && !pill) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
|
|
||||||
? status
|
|
||||||
: (status ? 'online' : 'offline');
|
|
||||||
var statusLabel = normalizedStatus === 'online'
|
|
||||||
? (label || 'Connected to player feed')
|
|
||||||
: normalizedStatus === 'offline'
|
|
||||||
? 'Disconnected from player feed'
|
|
||||||
: (label || 'Connecting to player feed');
|
|
||||||
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
|
|
||||||
|
|
||||||
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
|
|
||||||
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
|
|
||||||
dot.setAttribute('aria-label', statusLabel);
|
|
||||||
dot.setAttribute('title', statusLabel);
|
|
||||||
|
|
||||||
if (text) {
|
|
||||||
text.textContent = statusLabel;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pill) {
|
|
||||||
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
|
|
||||||
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
|
|
||||||
pill.textContent = pillLabel;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function connectDashboardSocket() {
|
|
||||||
if (!window.WebSocket) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
|
|
||||||
if (!hasStatusIndicators) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var socket = null;
|
|
||||||
var reconnectTimer = null;
|
|
||||||
|
|
||||||
function scheduleReconnect() {
|
|
||||||
if (reconnectTimer) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
reconnectTimer = window.setTimeout(function () {
|
|
||||||
reconnectTimer = null;
|
|
||||||
connect();
|
|
||||||
}, 5000);
|
|
||||||
}
|
|
||||||
|
|
||||||
function connect() {
|
|
||||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
||||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
|
|
||||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
|
||||||
|
|
||||||
socket.onmessage = function (event) {
|
|
||||||
try {
|
|
||||||
var payload = JSON.parse(String(event.data || '{}'));
|
|
||||||
if (payload && payload.type === 'dashboard-state') {
|
|
||||||
if (typeof window.webHandleDashboardState === 'function') {
|
|
||||||
window.webHandleDashboardState(payload.state);
|
|
||||||
}
|
|
||||||
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
// Ignore malformed dashboard payloads.
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onclose = function () {
|
|
||||||
updateSidebarStatus('offline');
|
|
||||||
scheduleReconnect();
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onerror = function () {
|
|
||||||
updateSidebarStatus('offline');
|
|
||||||
try {
|
|
||||||
socket.close();
|
|
||||||
} catch (_error) {
|
|
||||||
// ignore close errors
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
socket.onopen = function () {
|
|
||||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
connect();
|
|
||||||
}
|
|
||||||
|
|
||||||
connectDashboardSocket();
|
|
||||||
}());
|
|
||||||
|
|
||||||
@@ -1,171 +0,0 @@
|
|||||||
(function () {
|
|
||||||
var THEME_STORAGE_KEY = 'web-theme';
|
|
||||||
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
|
|
||||||
|
|
||||||
function getPreferredTheme() {
|
|
||||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoredTheme() {
|
|
||||||
try {
|
|
||||||
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
||||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
|
||||||
return storedTheme;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStoredTheme(theme) {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOrCreateCkeditorThemeStyleElement() {
|
|
||||||
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
|
|
||||||
|
|
||||||
if (styleElement) {
|
|
||||||
return styleElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
styleElement = document.createElement('style');
|
|
||||||
styleElement.id = CKEDITOR_THEME_STYLE_ID;
|
|
||||||
document.head.appendChild(styleElement);
|
|
||||||
|
|
||||||
return styleElement;
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCkeditorTheme(theme) {
|
|
||||||
var styleElement = getOrCreateCkeditorThemeStyleElement();
|
|
||||||
|
|
||||||
if (theme !== 'dark') {
|
|
||||||
styleElement.textContent = '';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
styleElement.textContent = [
|
|
||||||
'.ck-body-wrapper .ck.ck-dropdown__panel,',
|
|
||||||
'.ck-body-wrapper .ck.ck-list__panel,',
|
|
||||||
'.ck-body-wrapper .ck.ck-list,',
|
|
||||||
'.ck-body-wrapper .ck.ck-balloon-panel {',
|
|
||||||
' background: var(--bs-body-bg) !important;',
|
|
||||||
' background-color: var(--bs-body-bg) !important;',
|
|
||||||
' border-color: var(--bs-border-color) !important;',
|
|
||||||
' color: var(--bs-body-color) !important;',
|
|
||||||
'}',
|
|
||||||
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
|
|
||||||
' background: transparent !important;',
|
|
||||||
' background-color: transparent !important;',
|
|
||||||
' color: var(--bs-body-color) !important;',
|
|
||||||
'}',
|
|
||||||
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
|
|
||||||
' background: var(--bs-secondary-bg) !important;',
|
|
||||||
' background-color: var(--bs-secondary-bg) !important;',
|
|
||||||
'}',
|
|
||||||
'.ck-body-wrapper .ck.ck-color-grid,',
|
|
||||||
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
|
||||||
' color: var(--bs-body-color) !important;',
|
|
||||||
'}',
|
|
||||||
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
|
||||||
' border-color: var(--bs-border-color) !important;',
|
|
||||||
'}'
|
|
||||||
].join('\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(theme) {
|
|
||||||
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
|
|
||||||
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
|
|
||||||
|
|
||||||
document.documentElement.dataset.bsTheme = normalizedTheme;
|
|
||||||
document.documentElement.style.colorScheme = normalizedTheme;
|
|
||||||
syncCkeditorTheme(normalizedTheme);
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
|
|
||||||
var icon = toggleButton.querySelector('.theme-toggle__icon');
|
|
||||||
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
|
|
||||||
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
|
|
||||||
if (icon) {
|
|
||||||
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
|
|
||||||
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return normalizedTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initThemeToggle() {
|
|
||||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
|
|
||||||
var storedTheme = getStoredTheme();
|
|
||||||
var theme = storedTheme || getPreferredTheme();
|
|
||||||
|
|
||||||
applyTheme(theme);
|
|
||||||
|
|
||||||
if (!toggleButtons.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
|
||||||
toggleButton.addEventListener('click', function () {
|
|
||||||
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
setStoredTheme(nextTheme);
|
|
||||||
applyTheme(nextTheme);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSidebarToggle() {
|
|
||||||
var body = document.body;
|
|
||||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
|
|
||||||
var backdrop = document.querySelector('[data-sidebar-backdrop]');
|
|
||||||
|
|
||||||
if (!toggleButtons.length || !backdrop) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSidebarOpen(isOpen) {
|
|
||||||
body.classList.toggle('is-sidebar-open', isOpen);
|
|
||||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
|
||||||
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSidebar() {
|
|
||||||
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
|
||||||
toggleButton.addEventListener('click', function () {
|
|
||||||
toggleSidebar();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
backdrop.addEventListener('click', function () {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function (event) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.getPreferredTheme = getPreferredTheme;
|
|
||||||
window.getStoredTheme = getStoredTheme;
|
|
||||||
window.setStoredTheme = setStoredTheme;
|
|
||||||
window.applyTheme = applyTheme;
|
|
||||||
window.initThemeToggle = initThemeToggle;
|
|
||||||
window.initSidebarToggle = initSidebarToggle;
|
|
||||||
|
|
||||||
initThemeToggle();
|
|
||||||
initSidebarToggle();
|
|
||||||
}());
|
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
(function () {
|
|
||||||
function getBootstrapToast(toast) {
|
|
||||||
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return window.bootstrap.Toast.getOrCreateInstance(toast, {
|
|
||||||
autohide: true,
|
|
||||||
delay: 4000
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeToast(toast) {
|
|
||||||
if (toast && toast.parentNode) {
|
|
||||||
toast.parentNode.removeChild(toast);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function dismissToast(toast) {
|
|
||||||
var instance = getBootstrapToast(toast);
|
|
||||||
if (instance) {
|
|
||||||
instance.hide();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
removeToast(toast);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setToastVariant(toast, variant) {
|
|
||||||
if (!toast || !toast.classList) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextVariant = String(variant || 'info').trim().toLowerCase();
|
|
||||||
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
|
|
||||||
variants.forEach(function (value) {
|
|
||||||
toast.classList.remove('text-bg-' + value);
|
|
||||||
});
|
|
||||||
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getMessageVariant(message, fallbackVariant) {
|
|
||||||
var text = String(message || '').trim();
|
|
||||||
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
|
|
||||||
return 'danger';
|
|
||||||
}
|
|
||||||
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
|
|
||||||
return 'warning';
|
|
||||||
}
|
|
||||||
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
|
|
||||||
}
|
|
||||||
|
|
||||||
function showToast(message, variant) {
|
|
||||||
var text = String(message || '').trim();
|
|
||||||
if (!text) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var container = document.getElementById('app-toast-container');
|
|
||||||
if (!container) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingToast = document.getElementById('app-toast');
|
|
||||||
if (existingToast) {
|
|
||||||
var existingBody = existingToast.querySelector('.toast-body');
|
|
||||||
if (existingBody) {
|
|
||||||
existingBody.textContent = text;
|
|
||||||
}
|
|
||||||
var nextVariant = getMessageVariant(text, variant);
|
|
||||||
existingToast.setAttribute('data-toast-variant', nextVariant);
|
|
||||||
setToastVariant(existingToast, nextVariant);
|
|
||||||
var existingInstance = getBootstrapToast(existingToast);
|
|
||||||
if (existingInstance) {
|
|
||||||
existingInstance.show();
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var toast = document.createElement('div');
|
|
||||||
toast.className = 'toast align-items-center border-0';
|
|
||||||
toast.id = 'app-toast';
|
|
||||||
toast.setAttribute('role', 'status');
|
|
||||||
toast.setAttribute('aria-live', 'polite');
|
|
||||||
toast.setAttribute('aria-atomic', 'true');
|
|
||||||
toast.setAttribute('data-bs-autohide', 'true');
|
|
||||||
toast.setAttribute('data-bs-delay', '4000');
|
|
||||||
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
|
|
||||||
var toastVariant = getMessageVariant(text, variant);
|
|
||||||
toast.setAttribute('data-toast-variant', toastVariant);
|
|
||||||
setToastVariant(toast, toastVariant);
|
|
||||||
toast.querySelector('.toast-body').textContent = text;
|
|
||||||
|
|
||||||
toast.addEventListener('hidden.bs.toast', function () {
|
|
||||||
removeToast(toast);
|
|
||||||
});
|
|
||||||
|
|
||||||
container.appendChild(toast);
|
|
||||||
var instance = getBootstrapToast(toast);
|
|
||||||
if (instance) {
|
|
||||||
instance.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function initToast() {
|
|
||||||
var toast = document.getElementById('app-toast');
|
|
||||||
if (!toast) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
var url = new URL(window.location.href);
|
|
||||||
if (url.searchParams.has('message')) {
|
|
||||||
url.searchParams.delete('message');
|
|
||||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
// ignore URL cleanup failures
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
|
|
||||||
setToastVariant(toast, existingVariant);
|
|
||||||
|
|
||||||
var instance = getBootstrapToast(toast);
|
|
||||||
if (instance) {
|
|
||||||
instance.show();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
window.dismissToast = dismissToast;
|
|
||||||
window.showToast = showToast;
|
|
||||||
window.initToast = initToast;
|
|
||||||
|
|
||||||
initToast();
|
|
||||||
}());
|
|
||||||
@@ -19,7 +19,7 @@
|
|||||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||||
|
|
||||||
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateClientActionCell(cell, client) {
|
function updateClientActionCell(cell, client) {
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
|
|
||||||
var paused = Boolean(client.paused);
|
var paused = Boolean(client.paused);
|
||||||
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
||||||
pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||||
|
|
||||||
var pauseForm = pauseButton.form;
|
var pauseForm = pauseButton.form;
|
||||||
if (pauseForm) {
|
if (pauseForm) {
|
||||||
@@ -180,18 +180,50 @@
|
|||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScreenRow(screen) {
|
function renderScreenTile(screen) {
|
||||||
var clientCount = Number(screen.player_connection_count || 0);
|
var clientCount = Number(screen.player_connection_count || 0);
|
||||||
|
var playerUrl = String(screen.player_url || '').trim();
|
||||||
|
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
|
||||||
|
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
|
||||||
|
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
|
||||||
|
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<tr>',
|
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
|
||||||
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>',
|
'<div class="dashboard-screen-tile-top">',
|
||||||
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>',
|
'<div class="dashboard-screen-tile-text">',
|
||||||
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>',
|
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
|
||||||
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>',
|
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
|
||||||
'</tr>'
|
'</div>',
|
||||||
|
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
|
||||||
|
'</div>',
|
||||||
|
'<dl class="dashboard-screen-meta">',
|
||||||
|
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
|
||||||
|
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
|
||||||
|
'</dl>',
|
||||||
|
'</article>'
|
||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareScreensByConnectedClients(left, right) {
|
||||||
|
var leftCount = Number(left && left.player_connection_count || 0);
|
||||||
|
var rightCount = Number(right && right.player_connection_count || 0);
|
||||||
|
|
||||||
|
if (leftCount !== rightCount) {
|
||||||
|
return rightCount - leftCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leftName = String(left && left.name || '').trim();
|
||||||
|
var rightName = String(right && right.name || '').trim();
|
||||||
|
var nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
|
||||||
|
if (nameCompare !== 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
function updateStats(state) {
|
function updateStats(state) {
|
||||||
var clientCount = document.getElementById('dashboard-client-count');
|
var clientCount = document.getElementById('dashboard-client-count');
|
||||||
var screenCount = document.getElementById('dashboard-screen-count');
|
var screenCount = document.getElementById('dashboard-screen-count');
|
||||||
@@ -280,36 +312,52 @@
|
|||||||
tbody.removeChild(tbody.lastElementChild);
|
tbody.removeChild(tbody.lastElementChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.applyTableSort(document.getElementById('dashboard-clients-table'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScreenTable(state) {
|
function updateScreenGrid(state) {
|
||||||
var table = document.getElementById('dashboard-screens-table');
|
var grid = document.getElementById('dashboard-screens-grid');
|
||||||
if (!table || !Array.isArray(state.screens)) {
|
if (!grid || !Array.isArray(state.screens)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
var screens = state.screens.slice().sort(compareScreensByConnectedClients);
|
||||||
if (!tbody) {
|
|
||||||
|
if (!screens.length) {
|
||||||
|
grid.innerHTML = '<div class="empty dashboard-screen-empty">No screens yet.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.screens.length) {
|
grid.innerHTML = screens.map(renderScreenTile).join('');
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
|
|
||||||
window.applyTableSort(table);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardQuickActions(state) {
|
function updateDashboardQuickActions(state) {
|
||||||
|
var pauseButton = document.getElementById('dashboard-pause-all-button');
|
||||||
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
||||||
if (!blackoutButton || !state || !Array.isArray(state.clients)) {
|
if ((!pauseButton && !blackoutButton) || !state || !Array.isArray(state.clients)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasClients = state.clients.length > 0;
|
var hasClients = state.clients.length > 0;
|
||||||
|
var allPaused = hasClients && state.clients.every(function (client) {
|
||||||
|
return Boolean(client && client.paused);
|
||||||
|
});
|
||||||
var allBlackout = hasClients && state.clients.every(function (client) {
|
var allBlackout = hasClients && state.clients.every(function (client) {
|
||||||
return Boolean(client && client.blackout);
|
return Boolean(client && client.blackout);
|
||||||
});
|
});
|
||||||
|
if (pauseButton) {
|
||||||
|
var pauseForm = pauseButton.form;
|
||||||
|
var pauseInput = pauseForm ? pauseForm.querySelector('input[name="paused"]') : null;
|
||||||
|
var pauseLabel = allPaused ? 'Resume all clients' : 'Pause all clients';
|
||||||
|
var pauseButtonIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
|
||||||
|
pauseButton.innerHTML = '<i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
|
||||||
|
setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
|
||||||
|
if (pauseInput) {
|
||||||
|
pauseInput.value = allPaused ? 'false' : 'true';
|
||||||
|
}
|
||||||
|
if (pauseForm) {
|
||||||
|
pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?');
|
||||||
|
}
|
||||||
|
pauseButton.setAttribute('aria-label', pauseLabel);
|
||||||
|
}
|
||||||
|
|
||||||
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
||||||
var blackoutForm = blackoutButton.form;
|
var blackoutForm = blackoutButton.form;
|
||||||
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
||||||
@@ -331,7 +379,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateStats(state);
|
updateStats(state);
|
||||||
updateScreenTable(state);
|
updateScreenGrid(state);
|
||||||
updateClientTable(state);
|
updateClientTable(state);
|
||||||
updateDashboardQuickActions(state);
|
updateDashboardQuickActions(state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,141 +1,8 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var REFRESH_INTERVAL_MS = 10000;
|
if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
|
||||||
var pathname = window.location.pathname.replace(/\/$/, '');
|
|
||||||
var stateNode = document.getElementById('background-tasks-state');
|
|
||||||
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
|
||||||
|
|
||||||
if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getPageUrl(href) {
|
|
||||||
try {
|
|
||||||
return new URL(href, window.location.href);
|
|
||||||
} catch (_error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateLocalDateTimes(root) {
|
|
||||||
if (typeof window.initLocalDateTimes === 'function') {
|
|
||||||
window.initLocalDateTimes(root || document);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceSectionFromDocument(sectionName, responseDocument) {
|
|
||||||
var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]');
|
|
||||||
var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]');
|
|
||||||
|
|
||||||
if (!currentCard || !nextCard) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentCard.outerHTML = nextCard.outerHTML;
|
|
||||||
updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]'));
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceSummaryFromDocument(responseDocument) {
|
|
||||||
var currentSummary = document.getElementById('background-tasks-summary');
|
|
||||||
var nextSummary = responseDocument.getElementById('background-tasks-summary');
|
|
||||||
|
|
||||||
if (!currentSummary || !nextSummary) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentSummary.outerHTML = nextSummary.outerHTML;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceFiltersFromDocument(responseDocument) {
|
|
||||||
var currentFilters = document.getElementById('background-tasks-filters');
|
|
||||||
var nextFilters = responseDocument.getElementById('background-tasks-filters');
|
|
||||||
|
|
||||||
if (!currentFilters || !nextFilters) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentFilters.outerHTML = nextFilters.outerHTML;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceStateFromDocument(responseDocument) {
|
|
||||||
var currentState = document.getElementById('background-tasks-state');
|
|
||||||
var nextState = responseDocument.getElementById('background-tasks-state');
|
|
||||||
|
|
||||||
if (!currentState || !nextState) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentState.outerHTML = nextState.outerHTML;
|
|
||||||
stateNode = document.getElementById('background-tasks-state');
|
|
||||||
currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion;
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyPageResponse(responseDocument, sectionNames) {
|
|
||||||
var updated = false;
|
|
||||||
|
|
||||||
if (replaceStateFromDocument(responseDocument)) {
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replaceSummaryFromDocument(responseDocument)) {
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replaceFiltersFromDocument(responseDocument)) {
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
sectionNames.forEach(function (sectionName) {
|
|
||||||
if (replaceSectionFromDocument(sectionName, responseDocument)) {
|
|
||||||
updated = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return updated;
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadPage(nextUrl, options) {
|
|
||||||
var url = getPageUrl(nextUrl);
|
|
||||||
var sectionNames = options && options.sectionNames ? options.sectionNames : [];
|
|
||||||
var replaceHistory = Boolean(options && options.replaceHistory);
|
|
||||||
|
|
||||||
if (!url) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(url.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'text/html, application/xhtml+xml',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-store'
|
|
||||||
}).then(function (response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Unable to load background tasks page.');
|
|
||||||
}
|
|
||||||
return response.text();
|
|
||||||
}).then(function (text) {
|
|
||||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
|
||||||
if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (replaceHistory) {
|
|
||||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
|
||||||
} else {
|
|
||||||
window.history.pushState({}, document.title, url.pathname + url.search + url.hash);
|
|
||||||
}
|
|
||||||
}).catch(function (_error) {
|
|
||||||
window.location.assign(url.pathname + url.search + url.hash);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function stripMessageParameter() {
|
|
||||||
try {
|
try {
|
||||||
var url = new URL(window.location.href);
|
var url = new URL(window.location.href);
|
||||||
if (!url.searchParams.has('message')) {
|
if (!url.searchParams.has('message')) {
|
||||||
@@ -146,102 +13,4 @@
|
|||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
// Ignore URL cleanup failures.
|
// Ignore URL cleanup failures.
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
function findPaginationLink(target) {
|
|
||||||
if (!target || !target.closest) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var link = target.closest('.pagination .page-link');
|
|
||||||
if (!link) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var href = String(link.getAttribute('href') || '').trim();
|
|
||||||
if (!href || href === '#') {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var card = link.closest('[data-pagination-card]');
|
|
||||||
if (!card) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
href: href,
|
|
||||||
sectionName: String(card.getAttribute('data-pagination-card') || '').trim()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function initPaginationNavigation() {
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var paginationLink = findPaginationLink(event.target);
|
|
||||||
if (paginationLink && paginationLink.sectionName) {
|
|
||||||
var currentUrl = getPageUrl(window.location.href);
|
|
||||||
var nextUrl = getPageUrl(paginationLink.href);
|
|
||||||
if (!currentUrl || !nextUrl) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
window.addEventListener('popstate', function () {
|
|
||||||
loadPage(window.location.href, { replaceHistory: true });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function refreshPage() {
|
|
||||||
if (document.visibilityState !== 'visible') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch('/settings/background-tasks/state', {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'Accept': 'application/json',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-store'
|
|
||||||
}).then(function (response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}).then(function (payload) {
|
|
||||||
if (!payload || !payload.version) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var nextVersion = String(payload.version || '');
|
|
||||||
if (!currentVersion) {
|
|
||||||
currentVersion = nextVersion;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextVersion !== currentVersion) {
|
|
||||||
window.location.reload();
|
|
||||||
}
|
|
||||||
}).catch(function (_error) {
|
|
||||||
// Ignore refresh probe errors and try again on the next interval.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
stripMessageParameter();
|
|
||||||
initPaginationNavigation();
|
|
||||||
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
|
||||||
}());
|
}());
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,242 +0,0 @@
|
|||||||
(function () {
|
|
||||||
function getTableHeaderText(headerCell) {
|
|
||||||
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function cellHasButtonContent(cell) {
|
|
||||||
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCellSortValue(cell) {
|
|
||||||
if (!cell) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
|
|
||||||
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
|
|
||||||
return String(sortValue).trim();
|
|
||||||
}
|
|
||||||
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getComparableSortValue(rawValue) {
|
|
||||||
var value = String(rawValue || '').trim();
|
|
||||||
if (!value) {
|
|
||||||
return { type: 'empty', value: '' };
|
|
||||||
}
|
|
||||||
|
|
||||||
var numericValue = Number(value.replace(/,/g, ''));
|
|
||||||
if (!Number.isNaN(numericValue) && value !== '') {
|
|
||||||
return { type: 'number', value: numericValue };
|
|
||||||
}
|
|
||||||
|
|
||||||
var dateValue = Date.parse(value);
|
|
||||||
if (!Number.isNaN(dateValue)) {
|
|
||||||
return { type: 'date', value: dateValue };
|
|
||||||
}
|
|
||||||
|
|
||||||
return { type: 'string', value: value.toLowerCase() };
|
|
||||||
}
|
|
||||||
|
|
||||||
function compareSortValues(leftValue, rightValue) {
|
|
||||||
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
if (leftValue.type === 'empty') {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
if (rightValue.type === 'empty') {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (leftValue.type === rightValue.type) {
|
|
||||||
if (leftValue.value < rightValue.value) {
|
|
||||||
return -1;
|
|
||||||
}
|
|
||||||
if (leftValue.value > rightValue.value) {
|
|
||||||
return 1;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
|
|
||||||
}
|
|
||||||
|
|
||||||
function getSortableTableState(table) {
|
|
||||||
if (!table._sortableState) {
|
|
||||||
table._sortableState = {
|
|
||||||
columnIndex: null,
|
|
||||||
direction: 'asc'
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return table._sortableState;
|
|
||||||
}
|
|
||||||
|
|
||||||
function ensureSortableHeaderIndicator(headerCell) {
|
|
||||||
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
|
||||||
if (indicator) {
|
|
||||||
return indicator;
|
|
||||||
}
|
|
||||||
|
|
||||||
indicator = document.createElement('i');
|
|
||||||
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
|
|
||||||
indicator.setAttribute('aria-hidden', 'true');
|
|
||||||
headerCell.appendChild(indicator);
|
|
||||||
return indicator;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) {
|
|
||||||
if (!isSortable) {
|
|
||||||
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
|
||||||
if (hiddenIndicator) {
|
|
||||||
hiddenIndicator.style.display = 'none';
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var indicator = ensureSortableHeaderIndicator(headerCell);
|
|
||||||
indicator.style.display = '';
|
|
||||||
indicator.className = 'table-sort-indicator bi ms-1';
|
|
||||||
|
|
||||||
if (isActive && direction === 'desc') {
|
|
||||||
indicator.classList.add('bi-caret-down-fill');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isActive && direction === 'asc') {
|
|
||||||
indicator.classList.add('bi-caret-up-fill');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
indicator.classList.add('bi-arrow-down-up');
|
|
||||||
}
|
|
||||||
|
|
||||||
function isSortableTableColumn(table, columnIndex) {
|
|
||||||
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
|
|
||||||
if (!headerCell) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
|
|
||||||
for (var i = 0; i < bodies.length; i += 1) {
|
|
||||||
var rows = Array.prototype.slice.call(bodies[i].rows || []);
|
|
||||||
for (var j = 0; j < rows.length; j += 1) {
|
|
||||||
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
|
|
||||||
if (cell && cellHasButtonContent(cell)) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateSortableHeaderState(table) {
|
|
||||||
var state = getSortableTableState(table);
|
|
||||||
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
|
|
||||||
headerCells.forEach(function (headerCell, index) {
|
|
||||||
if (!headerCell) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var sortable = isSortableTableColumn(table, index);
|
|
||||||
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
|
|
||||||
headerCell.removeAttribute('aria-sort');
|
|
||||||
headerCell.removeAttribute('role');
|
|
||||||
headerCell.removeAttribute('tabindex');
|
|
||||||
|
|
||||||
if (sortable) {
|
|
||||||
headerCell.classList.add('sortable');
|
|
||||||
updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction);
|
|
||||||
headerCell.setAttribute('role', 'button');
|
|
||||||
headerCell.setAttribute('tabindex', '0');
|
|
||||||
if (state.columnIndex === index) {
|
|
||||||
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
|
|
||||||
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
|
|
||||||
} else {
|
|
||||||
headerCell.setAttribute('aria-sort', 'none');
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
headerCell.classList.add('unsortable');
|
|
||||||
updateSortableHeaderIndicator(headerCell, false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortTable(table, columnIndex, direction) {
|
|
||||||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
|
||||||
if (!tbody) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var rows = Array.prototype.slice.call(tbody.rows || []);
|
|
||||||
if (!rows.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var multiplier = direction === 'desc' ? -1 : 1;
|
|
||||||
rows.sort(function (leftRow, rightRow) {
|
|
||||||
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
|
|
||||||
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
|
|
||||||
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
|
|
||||||
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
|
|
||||||
return compareSortValues(leftComparable, rightComparable) * multiplier;
|
|
||||||
});
|
|
||||||
|
|
||||||
rows.forEach(function (row) {
|
|
||||||
tbody.appendChild(row);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTableSort(table) {
|
|
||||||
var state = getSortableTableState(table);
|
|
||||||
if (state.columnIndex === null || state.columnIndex === undefined) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
sortTable(table, state.columnIndex, state.direction);
|
|
||||||
updateSortableHeaderState(table);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSortableTables() {
|
|
||||||
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
|
|
||||||
tables.forEach(function (table) {
|
|
||||||
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
|
|
||||||
if (!headerRow) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
|
|
||||||
if (!isSortableTableColumn(table, index)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
ensureSortableHeaderIndicator(headerCell);
|
|
||||||
|
|
||||||
headerCell.addEventListener('click', function () {
|
|
||||||
var state = getSortableTableState(table);
|
|
||||||
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
|
|
||||||
state.columnIndex = index;
|
|
||||||
state.direction = nextDirection;
|
|
||||||
sortTable(table, index, nextDirection);
|
|
||||||
updateSortableHeaderState(table);
|
|
||||||
});
|
|
||||||
|
|
||||||
headerCell.addEventListener('keydown', function (event) {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
headerCell.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
updateSortableHeaderState(table);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.applyTableSort = applyTableSort;
|
|
||||||
window.initSortableTables = initSortableTables;
|
|
||||||
}());
|
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
(function () {
|
||||||
|
function rebindTableContainer(container) {
|
||||||
|
if (!container) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.initSortableTables === 'function') {
|
||||||
|
window.initSortableTables(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.initTableSearches === 'function') {
|
||||||
|
window.initTableSearches(container);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.initLocalDateTimes === 'function') {
|
||||||
|
window.initLocalDateTimes(container);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function focusSearchInput(input) {
|
||||||
|
if (!input || !input.focus) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.focus();
|
||||||
|
|
||||||
|
if (typeof input.setSelectionRange === 'function') {
|
||||||
|
var valueLength = String(input.value || '').length;
|
||||||
|
try {
|
||||||
|
input.setSelectionRange(valueLength, valueLength);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore selection failures on unsupported input types.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceTableResults(container, responseDocument) {
|
||||||
|
if (!container || !responseDocument || !responseDocument.querySelector) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentTable = container.querySelector('[data-table-searchable]');
|
||||||
|
var nextContainer = responseDocument.querySelector('[data-table-search-container]') || responseDocument.querySelector('.card');
|
||||||
|
var nextTable = nextContainer ? nextContainer.querySelector('[data-table-searchable]') : null;
|
||||||
|
var currentFooter = container.querySelector('.card-footer');
|
||||||
|
var nextFooter = nextContainer ? nextContainer.querySelector('.card-footer') : null;
|
||||||
|
|
||||||
|
if (!currentTable || !nextTable) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var currentBody = currentTable.tBodies && currentTable.tBodies[0] ? currentTable.tBodies[0] : null;
|
||||||
|
var nextBody = nextTable.tBodies && nextTable.tBodies[0] ? nextTable.tBodies[0] : null;
|
||||||
|
|
||||||
|
if (!currentBody || !nextBody) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentBody.outerHTML = nextBody.outerHTML;
|
||||||
|
|
||||||
|
if (currentFooter && nextFooter) {
|
||||||
|
currentFooter.outerHTML = nextFooter.outerHTML;
|
||||||
|
} else if (currentFooter && !nextFooter) {
|
||||||
|
currentFooter.parentNode.removeChild(currentFooter);
|
||||||
|
} else if (!currentFooter && nextFooter) {
|
||||||
|
currentTable.parentNode.appendChild(nextFooter.cloneNode(true));
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTableSearches(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
|
||||||
|
if (input.getAttribute('data-table-search-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.setAttribute('data-table-search-bound', 'true');
|
||||||
|
|
||||||
|
var searchParam = String(input.getAttribute('data-table-search-param') || 'search').trim() || 'search';
|
||||||
|
var currentUrl = new URL(window.location.href);
|
||||||
|
var pendingSearchTimer = null;
|
||||||
|
var requestSequence = 0;
|
||||||
|
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
|
||||||
|
|
||||||
|
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
|
||||||
|
|
||||||
|
function updateSearch() {
|
||||||
|
var query = String(input.value || '').trim();
|
||||||
|
var nextUrl = new URL(window.location.href);
|
||||||
|
|
||||||
|
if (query) {
|
||||||
|
nextUrl.searchParams.set(searchParam, query);
|
||||||
|
} else {
|
||||||
|
nextUrl.searchParams.delete(searchParam);
|
||||||
|
}
|
||||||
|
nextUrl.searchParams.delete('page');
|
||||||
|
|
||||||
|
if (nextUrl.toString() === currentUrl.toString()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
requestSequence += 1;
|
||||||
|
var sequenceId = requestSequence;
|
||||||
|
|
||||||
|
fetch(nextUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html, application/xhtml+xml',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to load search results.');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}).then(function (text) {
|
||||||
|
if (sequenceId !== requestSequence) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||||
|
|
||||||
|
if (window.history && window.history.replaceState) {
|
||||||
|
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceTableResults(container, responseDocument)) {
|
||||||
|
currentUrl = nextUrl;
|
||||||
|
rebindTableContainer(container || document);
|
||||||
|
focusSearchInput(input);
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
window.location.assign(nextUrl.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSearchUpdate() {
|
||||||
|
if (pendingSearchTimer) {
|
||||||
|
window.clearTimeout(pendingSearchTimer);
|
||||||
|
}
|
||||||
|
pendingSearchTimer = window.setTimeout(function () {
|
||||||
|
pendingSearchTimer = null;
|
||||||
|
updateSearch();
|
||||||
|
}, 300);
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', scheduleSearchUpdate);
|
||||||
|
input.addEventListener('search', scheduleSearchUpdate);
|
||||||
|
input.addEventListener('change', updateSearch);
|
||||||
|
input.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
event.preventDefault();
|
||||||
|
if (pendingSearchTimer) {
|
||||||
|
window.clearTimeout(pendingSearchTimer);
|
||||||
|
pendingSearchTimer = null;
|
||||||
|
}
|
||||||
|
updateSearch();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.initTableSearches = initTableSearches;
|
||||||
|
window.replaceTableResults = replaceTableResults;
|
||||||
|
}());
|
||||||
@@ -0,0 +1,197 @@
|
|||||||
|
(function () {
|
||||||
|
function getTableSortState(table) {
|
||||||
|
if (!table._tableSortState) {
|
||||||
|
table._tableSortState = {
|
||||||
|
sortKey: '',
|
||||||
|
direction: 'asc'
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return table._tableSortState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureSortableHeaderIndicator(headerCell) {
|
||||||
|
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
||||||
|
if (indicator) {
|
||||||
|
return indicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
indicator = document.createElement('i');
|
||||||
|
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
|
||||||
|
indicator.setAttribute('aria-hidden', 'true');
|
||||||
|
headerCell.appendChild(indicator);
|
||||||
|
return indicator;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSortableHeaderState(table) {
|
||||||
|
var state = getTableSortState(table);
|
||||||
|
var currentUrl = new URL(window.location.href);
|
||||||
|
var activeSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
|
||||||
|
var activeDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
|
||||||
|
|
||||||
|
state.sortKey = activeSortKey;
|
||||||
|
state.direction = activeDirection;
|
||||||
|
|
||||||
|
headerCells.forEach(function (headerCell) {
|
||||||
|
if (!headerCell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
|
||||||
|
var sortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
|
||||||
|
|
||||||
|
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
|
||||||
|
headerCell.removeAttribute('aria-sort');
|
||||||
|
|
||||||
|
if (!sortable) {
|
||||||
|
headerCell.classList.add('unsortable');
|
||||||
|
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
||||||
|
if (hiddenIndicator) {
|
||||||
|
hiddenIndicator.style.display = 'none';
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var indicator = ensureSortableHeaderIndicator(headerCell);
|
||||||
|
indicator.style.display = '';
|
||||||
|
indicator.className = 'table-sort-indicator bi ms-1';
|
||||||
|
headerCell.classList.add('sortable');
|
||||||
|
headerCell.setAttribute('role', 'button');
|
||||||
|
headerCell.setAttribute('tabindex', '0');
|
||||||
|
|
||||||
|
if (sortKey === activeSortKey) {
|
||||||
|
headerCell.classList.add(activeDirection === 'desc' ? 'sort-desc' : 'sort-asc');
|
||||||
|
headerCell.setAttribute('aria-sort', activeDirection === 'desc' ? 'descending' : 'ascending');
|
||||||
|
if (activeDirection === 'desc') {
|
||||||
|
indicator.classList.add('bi-caret-down-fill');
|
||||||
|
} else {
|
||||||
|
indicator.classList.add('bi-caret-up-fill');
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerCell.setAttribute('aria-sort', 'none');
|
||||||
|
indicator.classList.add('bi-arrow-down-up');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSortedUrl(sortKey) {
|
||||||
|
var currentUrl = new URL(window.location.href);
|
||||||
|
var nextUrl = new URL(window.location.href);
|
||||||
|
var currentSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
|
||||||
|
var currentDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
|
||||||
|
if (currentSortKey === sortKey) {
|
||||||
|
nextUrl.searchParams.set('direction', currentDirection === 'asc' ? 'desc' : 'asc');
|
||||||
|
} else {
|
||||||
|
nextUrl.searchParams.set('sort', sortKey);
|
||||||
|
nextUrl.searchParams.set('direction', 'asc');
|
||||||
|
}
|
||||||
|
|
||||||
|
nextUrl.searchParams.delete('page');
|
||||||
|
return nextUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrlState(nextUrl) {
|
||||||
|
if (window.history && window.history.replaceState) {
|
||||||
|
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function refreshSortedTable(table, nextUrl, requestSequence, sequenceId) {
|
||||||
|
var container = table ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) : null;
|
||||||
|
|
||||||
|
fetch(nextUrl.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html, application/xhtml+xml',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to load sorted results.');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}).then(function (text) {
|
||||||
|
if (sequenceId !== requestSequence.value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||||
|
updateUrlState(nextUrl);
|
||||||
|
|
||||||
|
if (window.replaceTableResults && replaceTableResults(container, responseDocument)) {
|
||||||
|
if (typeof window.initSortableTables === 'function') {
|
||||||
|
window.initSortableTables(container || document);
|
||||||
|
}
|
||||||
|
if (typeof window.initTableSearches === 'function') {
|
||||||
|
window.initTableSearches(container || document);
|
||||||
|
}
|
||||||
|
if (typeof window.initLocalDateTimes === 'function') {
|
||||||
|
window.initLocalDateTimes(container || document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
window.location.assign(nextUrl.toString());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initSortableTables(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('table[data-table-searchable]'), function (table) {
|
||||||
|
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
|
||||||
|
if (!headerRow) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (table.getAttribute('data-table-sort-bound') === 'true') {
|
||||||
|
updateSortableHeaderState(table);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.setAttribute('data-table-sort-bound', 'true');
|
||||||
|
var requestSequence = { value: 0 };
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(headerRow.cells, function (headerCell) {
|
||||||
|
if (!headerCell) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
|
||||||
|
var isSortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
|
||||||
|
if (!isSortable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (headerCell.getAttribute('data-table-sort-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
headerCell.setAttribute('data-table-sort-bound', 'true');
|
||||||
|
ensureSortableHeaderIndicator(headerCell);
|
||||||
|
|
||||||
|
headerCell.addEventListener('click', function () {
|
||||||
|
var nextUrl = buildSortedUrl(sortKey);
|
||||||
|
requestSequence.value += 1;
|
||||||
|
var sequenceId = requestSequence.value;
|
||||||
|
refreshSortedTable(table, nextUrl, requestSequence, sequenceId);
|
||||||
|
});
|
||||||
|
|
||||||
|
headerCell.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
headerCell.click();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
updateSortableHeaderState(table);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.initSortableTables = initSortableTables;
|
||||||
|
initSortableTables();
|
||||||
|
}());
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
(function () {
|
|
||||||
var THEME_STORAGE_KEY = 'web-theme';
|
|
||||||
|
|
||||||
function getPreferredTheme() {
|
|
||||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
|
||||||
return 'dark';
|
|
||||||
}
|
|
||||||
return 'light';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getStoredTheme() {
|
|
||||||
try {
|
|
||||||
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
|
||||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
|
||||||
return storedTheme;
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setStoredTheme(theme) {
|
|
||||||
try {
|
|
||||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
|
||||||
} catch (error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function applyTheme(theme) {
|
|
||||||
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
|
|
||||||
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
|
|
||||||
|
|
||||||
document.documentElement.dataset.bsTheme = normalizedTheme;
|
|
||||||
document.documentElement.style.colorScheme = normalizedTheme;
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
|
|
||||||
var icon = toggleButton.querySelector('.theme-toggle__icon');
|
|
||||||
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
|
|
||||||
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
|
|
||||||
if (icon) {
|
|
||||||
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
|
|
||||||
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return normalizedTheme;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initThemeToggle() {
|
|
||||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
|
|
||||||
var storedTheme = getStoredTheme();
|
|
||||||
var theme = storedTheme || getPreferredTheme();
|
|
||||||
|
|
||||||
applyTheme(theme);
|
|
||||||
|
|
||||||
if (!toggleButtons.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
|
||||||
toggleButton.addEventListener('click', function () {
|
|
||||||
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
|
|
||||||
setStoredTheme(nextTheme);
|
|
||||||
applyTheme(nextTheme);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSidebarToggle() {
|
|
||||||
var body = document.body;
|
|
||||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
|
|
||||||
var backdrop = document.querySelector('[data-sidebar-backdrop]');
|
|
||||||
|
|
||||||
if (!toggleButtons.length || !backdrop) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSidebarOpen(isOpen) {
|
|
||||||
body.classList.toggle('is-sidebar-open', isOpen);
|
|
||||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
|
||||||
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function toggleSidebar() {
|
|
||||||
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
|
|
||||||
}
|
|
||||||
|
|
||||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
|
||||||
toggleButton.addEventListener('click', function () {
|
|
||||||
toggleSidebar();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
backdrop.addEventListener('click', function () {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
});
|
|
||||||
|
|
||||||
document.addEventListener('keydown', function (event) {
|
|
||||||
if (event.key === 'Escape') {
|
|
||||||
setSidebarOpen(false);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
window.getPreferredTheme = getPreferredTheme;
|
|
||||||
window.getStoredTheme = getStoredTheme;
|
|
||||||
window.setStoredTheme = setStoredTheme;
|
|
||||||
window.applyTheme = applyTheme;
|
|
||||||
window.initThemeToggle = initThemeToggle;
|
|
||||||
window.initSidebarToggle = initSidebarToggle;
|
|
||||||
|
|
||||||
initThemeToggle();
|
|
||||||
initSidebarToggle();
|
|
||||||
}());
|
|
||||||
@@ -208,10 +208,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderSlidesPage({
|
res.send(pages.renderSlidesPage({
|
||||||
slides: data.slides || [],
|
slides: data.slides || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -413,10 +416,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderTemplatesPage({
|
res.send(pages.renderTemplatesPage({
|
||||||
templates: data.templates || [],
|
templates: data.templates || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -560,10 +566,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderCanvasSizesPage({
|
res.send(pages.renderCanvasSizesPage({
|
||||||
canvasSizes: data.canvasSizes || [],
|
canvasSizes: data.canvasSizes || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -199,7 +199,10 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
|||||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
const apiSources = (data.apiSources || []).map(function (apiSource) {
|
||||||
return Object.assign({}, apiSource, {
|
return Object.assign({}, apiSource, {
|
||||||
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
intervalLabel: apiSource.update_interval_unit === 'seconds'
|
||||||
@@ -211,7 +214,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
res.send(pages.renderApiSourcesPage({
|
res.send(pages.renderApiSourcesPage({
|
||||||
apiSources: apiSources,
|
apiSources: apiSources,
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -381,10 +384,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
|||||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderRssFeedsPage({
|
res.send(pages.renderRssFeedsPage({
|
||||||
rssFeeds: data.rssFeeds || [],
|
rssFeeds: data.rssFeeds || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||||
? req.body.blackout
|
? req.body.blackout
|
||||||
: req.query.blackout;
|
: req.query.blackout;
|
||||||
|
const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused')
|
||||||
|
? req.body.paused
|
||||||
|
: req.query.paused;
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
return res.status(400).json({ error: 'Command is required' });
|
return res.status(400).json({ error: 'Command is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command !== 'reload' && command !== 'blackout') {
|
if (command !== 'reload' && command !== 'blackout' && command !== 'pause') {
|
||||||
return res.status(400).json({ error: 'Unsupported command' });
|
return res.status(400).json({ error: 'Unsupported command' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,6 +59,11 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
command: 'blackout',
|
command: 'blackout',
|
||||||
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
|
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
|
||||||
}
|
}
|
||||||
|
: command === 'pause'
|
||||||
|
? {
|
||||||
|
command: 'pause',
|
||||||
|
paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false
|
||||||
|
}
|
||||||
: 'reload';
|
: 'reload';
|
||||||
|
|
||||||
const sentCount = await notifyPlayerScreens(slugs, payload);
|
const sentCount = await notifyPlayerScreens(slugs, payload);
|
||||||
@@ -65,7 +73,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
command: command,
|
command: command,
|
||||||
sent: sentCount,
|
sent: sentCount,
|
||||||
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined
|
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined,
|
||||||
|
paused: command === 'pause' ? Boolean(payload.paused) : undefined
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -162,10 +162,13 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
|||||||
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderRbacPage({
|
res.send(pages.renderRbacPage({
|
||||||
roles: data.roles || [],
|
roles: data.roles || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'roles', 'Role pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
|||||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
const mappedUsers = (data.users || []).map(function (user) {
|
const mappedUsers = (data.users || []).map(function (user) {
|
||||||
return Object.assign({}, user, {
|
return Object.assign({}, user, {
|
||||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||||
@@ -68,7 +71,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
res.send(pages.renderUsersPage({
|
res.send(pages.renderUsersPage({
|
||||||
users: mappedUsers,
|
users: mappedUsers,
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'users', 'Users pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'Users pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
const { renderView } = require('../../view');
|
const { renderView } = require('../../view');
|
||||||
const { hasAnyPermission } = require('../../../rbac');
|
const { hasAnyPermission } = require('../../../rbac');
|
||||||
const { buildPaginationPages } = require('../../lib/pagination');
|
const { buildPagination, buildQueryString } = require('../../lib/pagination');
|
||||||
|
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
|
||||||
|
|
||||||
const TASKS_PER_PAGE = 10;
|
const TASKS_PER_PAGE = 10;
|
||||||
const QUEUE_PAGE_URL = '/settings/background-tasks';
|
const QUEUE_PAGE_URL = '/settings/tasks-background';
|
||||||
const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled';
|
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
|
||||||
|
|
||||||
function formatIntervalLabel(intervalMs) {
|
function formatIntervalLabel(intervalMs) {
|
||||||
const value = Math.max(1, Number(intervalMs) || 0);
|
const value = Math.max(1, Number(intervalMs) || 0);
|
||||||
@@ -19,11 +20,6 @@ function formatIntervalLabel(intervalMs) {
|
|||||||
return `${value} ms`;
|
return `${value} ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePageNumber(value) {
|
|
||||||
const pageNumber = Math.floor(Number(value) || 1);
|
|
||||||
return Math.max(1, pageNumber);
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSourceFilter(sourceType, sourceId) {
|
function normalizeSourceFilter(sourceType, sourceId) {
|
||||||
const normalizedSourceType = String(sourceType || '').trim();
|
const normalizedSourceType = String(sourceType || '').trim();
|
||||||
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
||||||
@@ -38,48 +34,6 @@ function normalizeSourceFilter(sourceType, sourceId) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueryString(query) {
|
|
||||||
const searchParams = new URLSearchParams();
|
|
||||||
['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) {
|
|
||||||
const value = query && query[key];
|
|
||||||
if (value === undefined || value === null || value === '') {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
searchParams.set(key, String(value));
|
|
||||||
});
|
|
||||||
|
|
||||||
const queryString = searchParams.toString();
|
|
||||||
return queryString ? `?${queryString}` : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildPagination(totalItems, currentPage, pageParam, queryState) {
|
|
||||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
|
||||||
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
|
|
||||||
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
|
||||||
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
|
|
||||||
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
|
|
||||||
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
|
|
||||||
|
|
||||||
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
|
||||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
|
||||||
|
|
||||||
return {
|
|
||||||
currentPage: safeCurrentPage,
|
|
||||||
totalPages: totalPages,
|
|
||||||
totalItems: totalItems,
|
|
||||||
hasMultiplePages: totalPages > 1,
|
|
||||||
startItem: totalItems === 0 ? 0 : startIndex + 1,
|
|
||||||
endItem: endIndex,
|
|
||||||
hasPrevious: safeCurrentPage > 1,
|
|
||||||
hasNext: safeCurrentPage < totalPages,
|
|
||||||
previousUrl: buildQueryString(previousQuery),
|
|
||||||
nextUrl: buildQueryString(nextQuery),
|
|
||||||
pages: pages,
|
|
||||||
pageSize: TASKS_PER_PAGE,
|
|
||||||
pageParam: normalizedPageParam
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function taskMatchesFilters(task, sourceFilter) {
|
function taskMatchesFilters(task, sourceFilter) {
|
||||||
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
||||||
return true;
|
return true;
|
||||||
@@ -89,6 +43,8 @@ function taskMatchesFilters(task, sourceFilter) {
|
|||||||
return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId;
|
return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const taskMatchesSearch = createSearchMatcher;
|
||||||
|
|
||||||
function formatStatusLabel(status) {
|
function formatStatusLabel(status) {
|
||||||
const normalized = String(status || '').trim().toLowerCase();
|
const normalized = String(status || '').trim().toLowerCase();
|
||||||
if (!normalized) {
|
if (!normalized) {
|
||||||
@@ -133,25 +89,61 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
|||||||
|
|
||||||
function buildQueuePageViewModel(data, message, currentUser) {
|
function buildQueuePageViewModel(data, message, currentUser) {
|
||||||
const tasks = (data && data.tasks) || [];
|
const tasks = (data && data.tasks) || [];
|
||||||
const recurringTasks = (data && data.recurringTasks) || [];
|
|
||||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||||
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
|
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
|
||||||
|
const sort = String(data && data.sort || '').trim();
|
||||||
|
const direction = normalizeSortDirection(data && data.direction);
|
||||||
const queryState = {
|
const queryState = {
|
||||||
page: parsePageNumber(data && data.page),
|
page: parsePageNumber(data && data.page),
|
||||||
recurringPage: parsePageNumber(data && data.recurringPage),
|
|
||||||
sourceType: '',
|
sourceType: '',
|
||||||
sourceId: ''
|
sourceId: '',
|
||||||
|
search: String(data && data.search || '').trim(),
|
||||||
|
sort: sort,
|
||||||
|
direction: direction
|
||||||
};
|
};
|
||||||
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
|
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
|
||||||
queryState.sourceType = sourceFilter.sourceType;
|
queryState.sourceType = sourceFilter.sourceType;
|
||||||
queryState.sourceId = sourceFilter.sourceId;
|
queryState.sourceId = sourceFilter.sourceId;
|
||||||
const filteredTasks = tasks.filter(function (task) {
|
const filteredTasks = tasks.filter(function (task) {
|
||||||
return taskMatchesFilters(task, sourceFilter);
|
return taskMatchesFilters(task, sourceFilter) && taskMatchesSearch(queryState.search, [
|
||||||
|
'title',
|
||||||
|
'key',
|
||||||
|
'category',
|
||||||
|
'status',
|
||||||
|
'errorMessage',
|
||||||
|
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||||
|
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||||
|
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||||
|
])(task);
|
||||||
});
|
});
|
||||||
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
|
const sortedTasks = sortRows(filteredTasks, function (task) {
|
||||||
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
|
if (sort === 'task') {
|
||||||
const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
return task && task.title;
|
||||||
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
|
}
|
||||||
|
if (sort === 'category') {
|
||||||
|
return task && task.category;
|
||||||
|
}
|
||||||
|
if (sort === 'status') {
|
||||||
|
return task && task.status;
|
||||||
|
}
|
||||||
|
if (sort === 'queued') {
|
||||||
|
return task && task.createdAt;
|
||||||
|
}
|
||||||
|
if (sort === 'started') {
|
||||||
|
return task && task.startedAt;
|
||||||
|
}
|
||||||
|
if (sort === 'finished') {
|
||||||
|
return task && task.finishedAt;
|
||||||
|
}
|
||||||
|
if (sort === 'source') {
|
||||||
|
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
return task && task.createdAt;
|
||||||
|
}, direction);
|
||||||
|
const pagination = buildPagination(sortedTasks.length, queryState.page, 'page', queryState, TASKS_PER_PAGE, 'tasks', 'Background task pages');
|
||||||
|
const visibleTasks = sortedTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
||||||
const statusCards = [
|
const statusCards = [
|
||||||
{
|
{
|
||||||
status: 'queued',
|
status: 'queued',
|
||||||
@@ -199,16 +191,14 @@ function buildQueuePageViewModel(data, message, currentUser) {
|
|||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
scripts: ['js/settings/background-tasks.js'],
|
scripts: ['js/settings/background-tasks.js'],
|
||||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
|
||||||
backgroundTasksMenuOpen: true,
|
backgroundTasksMenuOpen: true,
|
||||||
tasks: visibleTasksWithSources,
|
tasks: visibleTasksWithSources,
|
||||||
summary: summary,
|
summary: summary,
|
||||||
statusCards: statusCards,
|
statusCards: statusCards,
|
||||||
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||||
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
|
clearFiltersUrl: QUEUE_PAGE_URL,
|
||||||
hasActiveFilters: hasActiveFilters,
|
hasActiveFilters: hasActiveFilters,
|
||||||
pagination: pagination,
|
pagination: pagination,
|
||||||
recurringPagination: recurringPagination,
|
|
||||||
canManage: canManage,
|
canManage: canManage,
|
||||||
canClearFinished: tasks.some(function (task) {
|
canClearFinished: tasks.some(function (task) {
|
||||||
return task.status !== 'queued' && task.status !== 'running';
|
return task.status !== 'queued' && task.status !== 'running';
|
||||||
@@ -220,6 +210,47 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
|||||||
const recurringTasks = (data && data.recurringTasks) || [];
|
const recurringTasks = (data && data.recurringTasks) || [];
|
||||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
|
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
|
||||||
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
|
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
|
||||||
|
const search = String(data && data.search || '').trim();
|
||||||
|
const sort = String(data && data.sort || '').trim();
|
||||||
|
const direction = normalizeSortDirection(data && data.direction);
|
||||||
|
const filteredRecurringTasks = recurringTasks.filter(function (task) {
|
||||||
|
return taskMatchesSearch(search, [
|
||||||
|
'title',
|
||||||
|
'intervalLabel',
|
||||||
|
'nextRunAt',
|
||||||
|
'lastRunAt',
|
||||||
|
'lastStatus',
|
||||||
|
'lastError',
|
||||||
|
function (item) { return item && item.metadata && item.metadata.sourceName; },
|
||||||
|
function (item) { return item && item.metadata && item.metadata.sourceType; },
|
||||||
|
function (item) { return item && item.metadata && item.metadata.sourceId; }
|
||||||
|
])(task);
|
||||||
|
});
|
||||||
|
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
|
||||||
|
if (sort === 'task') {
|
||||||
|
return task && task.title;
|
||||||
|
}
|
||||||
|
if (sort === 'interval') {
|
||||||
|
return task && task.intervalMs;
|
||||||
|
}
|
||||||
|
if (sort === 'next_run') {
|
||||||
|
return task && task.nextRunAt;
|
||||||
|
}
|
||||||
|
if (sort === 'last_run') {
|
||||||
|
return task && task.lastRunAt;
|
||||||
|
}
|
||||||
|
if (sort === 'last_result') {
|
||||||
|
return [task && task.lastStatus, task && task.lastError].map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
if (sort === 'source') {
|
||||||
|
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
return task && task.nextRunAt;
|
||||||
|
}, direction);
|
||||||
|
|
||||||
return renderView('settings/tasks-scheduled/index', {
|
return renderView('settings/tasks-scheduled/index', {
|
||||||
title: 'Scheduled tasks',
|
title: 'Scheduled tasks',
|
||||||
@@ -227,17 +258,16 @@ function buildScheduledPageViewModel(data, message, currentUser) {
|
|||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
scripts: ['js/settings/background-tasks.js'],
|
scripts: ['js/settings/background-tasks.js'],
|
||||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
|
||||||
backgroundTasksMenuOpen: true,
|
backgroundTasksMenuOpen: true,
|
||||||
recurringTasks: recurringTasks.map(function (task) {
|
recurringTasks: sortedRecurringTasks.map(function (task) {
|
||||||
return Object.assign({}, task, {
|
return Object.assign({}, task, {
|
||||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
summary: summary,
|
summary: summary,
|
||||||
canManage: canManage,
|
canManage: canManage,
|
||||||
scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0),
|
scheduledCount: Number(summary.scheduledCount || filteredRecurringTasks.length || 0),
|
||||||
recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) })
|
recurringPagination: buildPagination(sortedRecurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage), search: search, sort: sort, direction: direction })
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -42,65 +42,18 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
|
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueueVersion(tasks, recurringTasks, summary) {
|
app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
|
||||||
return [
|
|
||||||
'q:' + Number(summary && summary.counts && summary.counts.queued || 0),
|
|
||||||
'r:' + Number(summary && summary.counts && summary.counts.running || 0),
|
|
||||||
'f:' + Number(summary && summary.counts && summary.counts.failed || 0),
|
|
||||||
'c:' + Number(summary && summary.counts && summary.counts.completed || 0),
|
|
||||||
'a:' + Number(summary && summary.activeCount || 0),
|
|
||||||
't:' + Number(summary && summary.total || 0),
|
|
||||||
'tasks:' + (tasks || []).map(function (task) {
|
|
||||||
return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':');
|
|
||||||
}).join('|'),
|
|
||||||
'recurring:' + (recurringTasks || []).map(function (task) {
|
|
||||||
return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':');
|
|
||||||
}).join('|')
|
|
||||||
].join('~');
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) {
|
|
||||||
const tasks = backgroundTaskQueue.listTasks();
|
const tasks = backgroundTaskQueue.listTasks();
|
||||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||||
const summary = backgroundTaskQueue.getSummary();
|
const summary = backgroundTaskQueue.getSummary();
|
||||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/settings/background-tasks/scheduled', requireScheduledSettingsAccess(), function (req, res) {
|
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
|
||||||
const tasks = backgroundTaskQueue.listTasks();
|
const tasks = backgroundTaskQueue.listTasks();
|
||||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||||
const summary = backgroundTaskQueue.getSummary();
|
const summary = backgroundTaskQueue.getSummary();
|
||||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.get('/settings/background-tasks/state', function (req, res, next) {
|
|
||||||
if (!req.currentUser) {
|
|
||||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow', 'scheduled-tasks.read', 'scheduled-tasks.allow'])) {
|
|
||||||
const error = new Error('You do not have permission to access this area.');
|
|
||||||
error.statusCode = 403;
|
|
||||||
error.expose = true;
|
|
||||||
return next(error);
|
|
||||||
}
|
|
||||||
|
|
||||||
return next();
|
|
||||||
}, function (req, res) {
|
|
||||||
const tasks = backgroundTaskQueue.listTasks();
|
|
||||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
|
||||||
const summary = backgroundTaskQueue.getSummary();
|
|
||||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
|
||||||
|
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
||||||
res.json({
|
|
||||||
version: version,
|
|
||||||
summary: summary,
|
|
||||||
taskCount: tasks.length,
|
|
||||||
recurringCount: recurringTasks.length
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function requireSettingsManageAccess() {
|
function requireSettingsManageAccess() {
|
||||||
@@ -120,22 +73,7 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) {
|
function requireRecurringTaskAccess(req, res, next) {
|
||||||
const removedCount = backgroundTaskQueue.clearFinishedTasks();
|
|
||||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/settings/background-tasks/:id/cancel', requireSettingsManageAccess(), function (req, res) {
|
|
||||||
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
|
|
||||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/settings/background-tasks/:id/retry', requireSettingsManageAccess(), function (req, res) {
|
|
||||||
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
|
||||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
|
||||||
});
|
|
||||||
|
|
||||||
app.post('/settings/background-tasks/recurring/:key/run', function (req, res, next) {
|
|
||||||
if (!req.currentUser) {
|
if (!req.currentUser) {
|
||||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||||
}
|
}
|
||||||
@@ -148,8 +86,25 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return next();
|
return next();
|
||||||
}, function (req, res) {
|
}
|
||||||
|
|
||||||
|
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), function (req, res) {
|
||||||
|
const removedCount = backgroundTaskQueue.clearFinishedTasks();
|
||||||
|
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), function (req, res) {
|
||||||
|
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
|
||||||
|
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) {
|
||||||
|
const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||||
|
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, function (req, res) {
|
||||||
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
||||||
res.redirect('/settings/background-tasks/scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
|
|||||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderCanvasSizesPage({
|
res.send(pages.renderCanvasSizesPage({
|
||||||
canvasSizes: data.canvasSizes || [],
|
canvasSizes: data.canvasSizes || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -1,22 +1,65 @@
|
|||||||
module.exports = function registerClientsRoutes(app, deps) {
|
module.exports = function registerClientsRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
const common = deps.common;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const buildDashboardState = deps.buildDashboardState;
|
const buildDashboardState = deps.buildDashboardState;
|
||||||
const { buildPagination } = require('../../../lib/pagination');
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
|
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 10;
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
|
function sortClients(clients, sortKey, sortDirection) {
|
||||||
|
const normalizedSortKey = String(sortKey || '').trim();
|
||||||
|
const normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
const accessors = {
|
||||||
|
client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); },
|
||||||
|
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
|
||||||
|
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
|
||||||
|
ip: function (client) { return String(client && client.clientIp || '').trim(); },
|
||||||
|
viewport: function (client) {
|
||||||
|
const viewport = client && client.viewport;
|
||||||
|
if (!viewport || !viewport.width || !viewport.height) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
return `${Number(viewport.width) || 0}x${Number(viewport.height) || 0}`;
|
||||||
|
},
|
||||||
|
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!accessors[normalizedSortKey]) {
|
||||||
|
return Array.isArray(clients) ? clients.slice() : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return sortRows(clients, function (client) {
|
||||||
|
return accessors[normalizedSortKey](client);
|
||||||
|
}, normalizedDirection);
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
|
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await buildDashboardState(pool);
|
const data = await buildDashboardState(pool);
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const totalItems = Array.isArray(data.clients) ? data.clients.length : 0;
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const matchesSearch = createSearchMatcher(search, [
|
||||||
|
'name',
|
||||||
|
'client_name',
|
||||||
|
'clientId',
|
||||||
|
'deviceId',
|
||||||
|
'slug',
|
||||||
|
'screen_slug',
|
||||||
|
'ipAddress',
|
||||||
|
'status'
|
||||||
|
]);
|
||||||
|
const filteredClients = (data.clients || []).filter(matchesSearch);
|
||||||
|
const sortedClients = sortClients(filteredClients, sort, direction);
|
||||||
const startIndex = (page - 1) * LIST_PAGE_SIZE;
|
const startIndex = (page - 1) * LIST_PAGE_SIZE;
|
||||||
const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE);
|
const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
|
||||||
res.send(pages.renderConnectedClientsPage({
|
res.send(pages.renderConnectedClientsPage({
|
||||||
clients: clients,
|
clients: clients,
|
||||||
pagination: buildPagination(totalItems, page, 'page', {}, LIST_PAGE_SIZE, 'clients', 'Client pages')
|
pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -17,6 +17,9 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
|||||||
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
if (req.query.edit) {
|
if (req.query.edit) {
|
||||||
const data = await common.fetchAdminData(pool);
|
const data = await common.fetchAdminData(pool);
|
||||||
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
||||||
@@ -25,10 +28,10 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
}
|
}
|
||||||
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE);
|
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderPlaylistsPage({
|
res.send(pages.renderPlaylistsPage({
|
||||||
playlists: data.playlists || [],
|
playlists: data.playlists || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -15,10 +15,39 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyConnectionCounts(screens, dashboardScreens) {
|
||||||
|
const countsById = new Map();
|
||||||
|
const countsBySlug = new Map();
|
||||||
|
|
||||||
|
(dashboardScreens || []).forEach(function (screen) {
|
||||||
|
const count = Number(screen && screen.player_connection_count || 0);
|
||||||
|
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
|
||||||
|
const slug = String(screen && screen.slug || '').trim();
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
countsById.set(id, count);
|
||||||
|
}
|
||||||
|
if (slug) {
|
||||||
|
countsBySlug.set(slug, count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (screens || []).map(function (screen) {
|
||||||
|
const slug = String(screen && screen.slug || '').trim();
|
||||||
|
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
|
||||||
|
const playerConnectionCount = countsBySlug.has(slug)
|
||||||
|
? countsBySlug.get(slug)
|
||||||
|
: countsById.get(id) || 0;
|
||||||
|
|
||||||
|
return Object.assign({}, screen, {
|
||||||
|
player_connection_count: playerConnectionCount
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
if (req.query.edit) {
|
if (req.query.edit) {
|
||||||
const data = await buildDashboardState(pool);
|
|
||||||
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
return res.status(404).send('Screen not found');
|
return res.status(404).send('Screen not found');
|
||||||
@@ -27,10 +56,14 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
}
|
}
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const dashboardState = await buildDashboardState(pool);
|
||||||
|
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderScreensPage({
|
res.send(pages.renderScreensPage({
|
||||||
screens: data.screens || [],
|
screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
|||||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderSlidesPage({
|
res.send(pages.renderSlidesPage({
|
||||||
slides: data.slides || [],
|
slides: data.slides || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ module.exports = function registerTemplatesRoutes(app, deps) {
|
|||||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
|
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
res.send(pages.renderTemplatesPage({
|
res.send(pages.renderTemplatesPage({
|
||||||
templates: data.templates || [],
|
templates: data.templates || [],
|
||||||
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||||
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
+1
-1
@@ -110,7 +110,7 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
|
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||||
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8'));
|
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
|
||||||
|
|
||||||
function resolveTemplatePath(relativePath) {
|
function resolveTemplatePath(relativePath) {
|
||||||
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
|
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
|
||||||
|
|||||||
@@ -22,11 +22,11 @@
|
|||||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th>API URL</th>
|
<th data-table-sort-key="url">API URL</th>
|
||||||
<th>Refresh interval</th>
|
<th data-table-sort-key="interval">Refresh interval</th>
|
||||||
<th>Last pulled</th>
|
<th data-table-sort-key="last_pulled">Last pulled</th>
|
||||||
<th>Last response</th>
|
<th data-table-sort-key="last_response">Last response</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th>Feed URL</th>
|
<th data-table-sort-key="url">Feed URL</th>
|
||||||
<th>Refresh interval</th>
|
<th data-table-sort-key="interval">Refresh interval</th>
|
||||||
<th>Items pulled</th>
|
<th data-table-sort-key="items">Items pulled</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -24,10 +24,10 @@
|
|||||||
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Role</th>
|
<th data-table-sort-key="role">Role</th>
|
||||||
<th>Description</th>
|
<th data-table-sort-key="description">Description</th>
|
||||||
<th>Users</th>
|
<th data-table-sort-key="users">Users</th>
|
||||||
<th>Permissions</th>
|
<th data-table-sort-key="permissions">Permissions</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Background tasks</h2>
|
<h2>Background tasks</h2>
|
||||||
@@ -41,7 +39,7 @@
|
|||||||
</div>
|
</div>
|
||||||
{{#if canManage}}
|
{{#if canManage}}
|
||||||
{{#if canClearFinished}}
|
{{#if canClearFinished}}
|
||||||
<form method="post" action="/settings/background-tasks/clear-finished">
|
<form method="post" action="/settings/tasks-background/clear-finished">
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
||||||
</form>
|
</form>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
@@ -52,13 +50,13 @@
|
|||||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Task</th>
|
<th data-table-sort-key="task">Task</th>
|
||||||
<th>Category</th>
|
<th data-table-sort-key="category">Category</th>
|
||||||
<th>Status</th>
|
<th data-table-sort-key="status">Status</th>
|
||||||
<th>Queued</th>
|
<th data-table-sort-key="queued">Queued</th>
|
||||||
<th>Started</th>
|
<th data-table-sort-key="started">Started</th>
|
||||||
<th>Finished</th>
|
<th data-table-sort-key="finished">Finished</th>
|
||||||
<th>Source</th>
|
<th data-table-sort-key="source">Source</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -129,27 +127,6 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
{{#if pagination.hasMultiplePages}}
|
{{> table-pagination pagination=pagination basePath="/settings/tasks-background"}}
|
||||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
|
||||||
<div class="text-muted small">
|
|
||||||
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
|
|
||||||
</div>
|
|
||||||
<nav aria-label="Background task pages">
|
|
||||||
<ul class="pagination pagination-sm mb-0">
|
|
||||||
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
|
||||||
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
|
||||||
</li>
|
|
||||||
{{#each pagination.pages}}
|
|
||||||
<li class="page-item {{#if active}}active{{/if}}">
|
|
||||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
|
||||||
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
|
||||||
|
|
||||||
<div class="page-header">
|
<div class="page-header">
|
||||||
<div>
|
<div>
|
||||||
<h2>Scheduled tasks</h2>
|
<h2>Scheduled tasks</h2>
|
||||||
@@ -21,12 +19,12 @@
|
|||||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Task</th>
|
<th data-table-sort-key="task">Task</th>
|
||||||
<th>Interval</th>
|
<th data-table-sort-key="interval">Interval</th>
|
||||||
<th>Next run</th>
|
<th data-table-sort-key="next_run">Next run</th>
|
||||||
<th>Last run</th>
|
<th data-table-sort-key="last_run">Last run</th>
|
||||||
<th>Last result</th>
|
<th data-table-sort-key="last_result">Last result</th>
|
||||||
<th>Source</th>
|
<th data-table-sort-key="source">Source</th>
|
||||||
{{#if canManage}}<th>Actions</th>{{/if}}
|
{{#if canManage}}<th>Actions</th>{{/if}}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -68,7 +66,7 @@
|
|||||||
{{#if ../canManage}}
|
{{#if ../canManage}}
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<form method="post" action="/settings/background-tasks/recurring/{{key}}/run">
|
<form method="post" action="/settings/tasks-scheduled/recurring/{{key}}/run">
|
||||||
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
|
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th data-table-sort-key="user">User</th>
|
||||||
<th>Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th>Roles</th>
|
<th data-table-sort-key="roles">Roles</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -83,9 +83,11 @@
|
|||||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||||
<script src="/assets/js/web-ui-helpers.js"></script>
|
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||||
<script src="/assets/js/admin/toast.js"></script>
|
<script src="/assets/js/toast.js"></script>
|
||||||
|
<script src="/assets/js/table/table-sort.js"></script>
|
||||||
|
<script src="/assets/js/table/table-search.js"></script>
|
||||||
<script src="/assets/js/admin/admin-page.js"></script>
|
<script src="/assets/js/admin/admin-page.js"></script>
|
||||||
<script src="/assets/js/admin/system-status.js"></script>
|
<script src="/assets/js/system-status.js"></script>
|
||||||
{{#if scripts.length}}
|
{{#if scripts.length}}
|
||||||
{{#each scripts}}
|
{{#each scripts}}
|
||||||
<script src="/assets/{{this}}"></script>
|
<script src="/assets/{{this}}"></script>
|
||||||
|
|||||||
@@ -23,7 +23,7 @@
|
|||||||
{{{body}}}
|
{{{body}}}
|
||||||
</main>
|
</main>
|
||||||
<script src="/assets/js/admin/admin-page.js"></script>
|
<script src="/assets/js/admin/admin-page.js"></script>
|
||||||
<script src="/assets/js/admin/system-status.js"></script>
|
<script src="/assets/js/system-status.js"></script>
|
||||||
{{else if authShell}}
|
{{else if authShell}}
|
||||||
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
|
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
|
||||||
<div class="login-box">
|
<div class="login-box">
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
<script src="/assets/js/admin/admin-page.js"></script>
|
<script src="/assets/js/admin/admin-page.js"></script>
|
||||||
<script src="/assets/js/admin/system-status.js"></script>
|
<script src="/assets/js/system-status.js"></script>
|
||||||
{{else}}
|
{{else}}
|
||||||
<div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3">
|
<div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3">
|
||||||
{{#if message}}
|
{{#if message}}
|
||||||
@@ -243,7 +243,7 @@
|
|||||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||||
<ul class="nav nav-treeview">
|
<ul class="nav nav-treeview">
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
|
||||||
<i class="nav-icon bi bi-list-ol"></i>
|
<i class="nav-icon bi bi-list-ol"></i>
|
||||||
<p>Task queue</p>
|
<p>Task queue</p>
|
||||||
</a>
|
</a>
|
||||||
@@ -251,7 +251,7 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
|
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/background-tasks/scheduled">
|
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/tasks-scheduled">
|
||||||
<i class="nav-icon bi bi-calendar-event"></i>
|
<i class="nav-icon bi bi-calendar-event"></i>
|
||||||
<p>Scheduled tasks</p>
|
<p>Scheduled tasks</p>
|
||||||
</a>
|
</a>
|
||||||
@@ -285,8 +285,9 @@
|
|||||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||||
<script src="/assets/js/web-ui-helpers.js"></script>
|
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||||
<script src="/assets/js/admin/toast.js"></script>
|
<script src="/assets/js/toast.js"></script>
|
||||||
<script src="/assets/js/table-sort.js"></script>
|
<script src="/assets/js/table/table-sort.js"></script>
|
||||||
|
<script src="/assets/js/table/table-search.js"></script>
|
||||||
<script src="/assets/js/admin/admin-page.js"></script>
|
<script src="/assets/js/admin/admin-page.js"></script>
|
||||||
{{#if scripts.length}}
|
{{#if scripts.length}}
|
||||||
{{#each scripts}}
|
{{#each scripts}}
|
||||||
@@ -297,7 +298,7 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
<script src="/assets/js/admin/system-status.js"></script>
|
<script src="/assets/js/system-status.js"></script>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+8
-2
@@ -1,17 +1,23 @@
|
|||||||
{{#if pagination.hasMultiplePages}}
|
{{#if pagination.hasMultiplePages}}
|
||||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
<div class="card-footer d-flex flex-wrap align-items-center">
|
||||||
<div class="text-muted small">
|
<div class="text-muted small">
|
||||||
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
|
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
|
||||||
</div>
|
</div>
|
||||||
<nav aria-label="{{pagination.ariaLabel}}">
|
<nav class="ms-auto" aria-label="{{pagination.ariaLabel}}">
|
||||||
<ul class="pagination pagination-sm mb-0">
|
<ul class="pagination pagination-sm mb-0">
|
||||||
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
||||||
<a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
<a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||||
</li>
|
</li>
|
||||||
{{#each pagination.pages}}
|
{{#each pagination.pages}}
|
||||||
|
{{#if ellipsis}}
|
||||||
|
<li class="page-item disabled" aria-hidden="true">
|
||||||
|
<span class="page-link">...</span>
|
||||||
|
</li>
|
||||||
|
{{else}}
|
||||||
<li class="page-item {{#if active}}active{{/if}}">
|
<li class="page-item {{#if active}}active{{/if}}">
|
||||||
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
|
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
|
||||||
</li>
|
</li>
|
||||||
|
{{/if}}
|
||||||
{{/each}}
|
{{/each}}
|
||||||
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
||||||
<a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
<a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||||
@@ -22,9 +22,9 @@
|
|||||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th>Dimensions</th>
|
<th data-table-sort-key="dimensions">Dimensions</th>
|
||||||
<th>Templates</th>
|
<th data-table-sort-key="templates">Templates</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -19,12 +19,12 @@
|
|||||||
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable>
|
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Client</th>
|
<th data-table-sort-key="client">Client</th>
|
||||||
<th>Current Screen</th>
|
<th data-table-sort-key="screen">Current Screen</th>
|
||||||
<th>Current Slide</th>
|
<th data-table-sort-key="slide">Current Slide</th>
|
||||||
<th>IP</th>
|
<th data-table-sort-key="ip">IP</th>
|
||||||
<th>Viewport</th>
|
<th data-table-sort-key="viewport">Viewport</th>
|
||||||
<th>Connected/Updated</th>
|
<th data-table-sort-key="connected">Connected/Updated</th>
|
||||||
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
|
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
<div>
|
<div>
|
||||||
<span class="dashboard-hero-kicker">Live overview</span>
|
<span class="dashboard-hero-kicker">Live overview</span>
|
||||||
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
|
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
|
||||||
<p class="dashboard-hero-copy">Use the cards below for the current totals, then jump to the screens table when you need a deeper look at playback and connection state.</p>
|
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen snapshot when you want a quick read on playlist assignment and live connections.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="dashboard-hero-stats">
|
<div class="dashboard-hero-stats">
|
||||||
{{#if (hasPermission currentUser "playlists.read")}}
|
{{#if (hasPermission currentUser "playlists.read")}}
|
||||||
@@ -63,6 +63,22 @@
|
|||||||
><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button>
|
><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button>
|
||||||
<span class="dashboard-action-help">Restart every connected player page.</span>
|
<span class="dashboard-action-help">Restart every connected player page.</span>
|
||||||
</form>
|
</form>
|
||||||
|
<form
|
||||||
|
method="post"
|
||||||
|
action="/commands"
|
||||||
|
class="dashboard-action-form"
|
||||||
|
data-confirm-message="Pause all connected clients?"
|
||||||
|
data-async-command
|
||||||
|
>
|
||||||
|
<input type="hidden" name="command" value="pause" />
|
||||||
|
<input type="hidden" name="paused" value="true" />
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
class="btn btn-info dashboard-action-button"
|
||||||
|
id="dashboard-pause-all-button"
|
||||||
|
><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause all clients</button>
|
||||||
|
<span class="dashboard-action-help">Freeze playback on every connected player.</span>
|
||||||
|
</form>
|
||||||
<form
|
<form
|
||||||
method="post"
|
method="post"
|
||||||
action="/commands"
|
action="/commands"
|
||||||
@@ -91,46 +107,49 @@
|
|||||||
{{#if (hasPermission currentUser "screens.read")}}
|
{{#if (hasPermission currentUser "screens.read")}}
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card card-outline card-secondary dashboard-table-card">
|
<div class="card card-outline card-secondary dashboard-screen-card-shell">
|
||||||
<div class="card-header">
|
<div class="card-header dashboard-screen-card-header">
|
||||||
<div class="dashboard-card-heading">
|
<div class="dashboard-card-heading">
|
||||||
<h3 class="card-title">Current screens</h3>
|
<h3 class="card-title">Screen snapshot</h3>
|
||||||
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
|
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
|
||||||
and live connection count.</p>
|
and live connection count without the spreadsheet feel.</p>
|
||||||
</div>
|
</div>
|
||||||
|
<a class="btn btn-sm btn-outline-primary" href="/screens">Manage screens</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body">
|
||||||
<table
|
<div id="dashboard-screens-grid" class="dashboard-screen-grid">
|
||||||
id="dashboard-screens-table"
|
|
||||||
class="table table-striped w-100 mb-0"
|
|
||||||
>
|
|
||||||
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th
|
|
||||||
>Connected clients</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{{#if screens.length}}
|
{{#if screens.length}}
|
||||||
{{#each screens}}
|
{{#each screens}}
|
||||||
<tr data-client-key="{{id}}">
|
<article class="dashboard-screen-tile" data-screen-key="{{id}}">
|
||||||
<td data-label="Name">{{name}}</td>
|
<div class="dashboard-screen-tile-top">
|
||||||
<td data-label="Player URL"><a
|
<div class="dashboard-screen-tile-text">
|
||||||
href="{{playerUrl slug}}"
|
<h4 class="dashboard-screen-name">{{name}}</h4>
|
||||||
target="_blank"
|
<a class="dashboard-screen-link" href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a>
|
||||||
>{{playerUrl slug}}</a></td>
|
</div>
|
||||||
<td data-label="Playlist">{{playlist_name}}</td>
|
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
|
||||||
<td data-label="Connected clients">
|
|
||||||
{{#if player_connection_count}}
|
{{#if player_connection_count}}
|
||||||
<div class="connection-count">{{player_connection_count}}
|
{{player_connection_count}} live
|
||||||
connected</div>
|
|
||||||
{{else}}
|
{{else}}
|
||||||
<span class="empty">No clients connected.</span>
|
No clients
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</td>
|
</span>
|
||||||
</tr>
|
</div>
|
||||||
|
<dl class="dashboard-screen-meta">
|
||||||
|
<div>
|
||||||
|
<dt>Playlist</dt>
|
||||||
|
<dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<dt>Connections</dt>
|
||||||
|
<dd>{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
</article>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="4" class="empty">No screens yet.</td></tr>
|
<div class="empty dashboard-screen-empty">No screens yet.</div>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -22,8 +22,8 @@
|
|||||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th>Slides</th>
|
<th data-table-sort-key="slides">Slides</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -22,9 +22,9 @@
|
|||||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th>Player URL</th>
|
<th data-table-sort-key="url">Player URL</th>
|
||||||
<th>Playlist</th>
|
<th data-table-sort-key="playlist">Playlist</th>
|
||||||
<th>Connected clients</th>
|
<th>Connected clients</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
|
|||||||
@@ -23,8 +23,8 @@
|
|||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-sortable="false">Thumbnail</th>
|
<th data-sortable="false">Thumbnail</th>
|
||||||
<th>Title</th>
|
<th data-table-sort-key="title">Title</th>
|
||||||
<th>Template</th>
|
<th data-table-sort-key="template">Template</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
@@ -22,10 +22,10 @@
|
|||||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th data-table-sort-key="name">Name</th>
|
||||||
<th>Canvas</th>
|
<th data-table-sort-key="canvas">Canvas</th>
|
||||||
<th>Regions</th>
|
<th data-table-sort-key="regions">Regions</th>
|
||||||
<th>Slides used by</th>
|
<th data-table-sort-key="slides">Slides used by</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|||||||
Reference in New Issue
Block a user