diff --git a/CHANGELOG.md b/CHANGELOG.md
index c97073b..53bbb88 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,18 @@ All notable changes to this project will be documented in this file.
- No unreleased changes recorded yet.
+## 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
### Changed
@@ -58,13 +70,6 @@ All notable changes to this project will be documented in this file.
### Fixed
-- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
-- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
-
-## 1.5.7 - 2026-07-25
-
-### Fixed
-
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
## 1.5.6 - 2026-07-25
diff --git a/package-lock.json b/package-lock.json
index b40b4ae..0e2b8b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
- "version": "1.5.12",
+ "version": "1.5.13",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
- "version": "1.5.12",
+ "version": "1.5.13",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
diff --git a/package.json b/package.json
index ab90940..42427fc 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
- "version": "1.5.12",
+ "version": "1.5.13",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
diff --git a/src/common.js b/src/common.js
index 8b4fbf7..4e6af73 100644
--- a/src/common.js
+++ b/src/common.js
@@ -2,6 +2,18 @@ const db = require('./db');
const data = require('./data');
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 = {
createPool: db.createPool,
ensureSchema: db.ensureSchema,
@@ -14,6 +26,9 @@ module.exports = {
fetchTemplatesPage: data.fetchTemplatesPage,
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
fetchScreensPage: data.fetchScreensPage,
+ getSearchQuery: getSearchQuery,
+ getSortQuery: getSortQuery,
+ getSortDirectionQuery: getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById,
fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage,
diff --git a/src/data/admin.js b/src/data/admin.js
index 35e895a..440f3fb 100644
--- a/src/data/admin.js
+++ b/src/data/admin.js
@@ -35,13 +35,23 @@ async function fetchAdminData(pool) {
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, {
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
FROM playlists p
ORDER BY p.id DESC`,
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,
pageSize: pageSize
});
@@ -49,7 +59,7 @@ async function fetchPlaylistsPage(pool, page, pageSize) {
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, {
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
@@ -57,6 +67,16 @@ async function fetchSlidesPage(pool, page, pageSize) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC`,
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,
pageSize: pageSize
});
@@ -64,7 +84,7 @@ async function fetchSlidesPage(pool, page, pageSize) {
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, {
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,
@@ -74,6 +94,18 @@ async function fetchTemplatesPage(pool, page, pageSize) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC`,
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,
pageSize: pageSize
});
@@ -81,13 +113,24 @@ async function fetchTemplatesPage(pool, page, pageSize) {
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, {
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
FROM canvas_sizes
ORDER BY width ASC, height ASC, name ASC`,
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,
pageSize: pageSize
});
@@ -95,13 +138,24 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
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, {
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
LEFT JOIN playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC`,
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,
pageSize: pageSize
});
diff --git a/src/data/api-sources.js b/src/data/api-sources.js
index af6623e..6e7ea65 100644
--- a/src/data/api-sources.js
+++ b/src/data/api-sources.js
@@ -15,10 +15,23 @@ async function fetchApiSourcesData(pool) {
return { apiSources: apiSources };
}
-async function fetchApiSourcesPage(pool, page, pageSize) {
+async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
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',
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,
pageSize: pageSize
});
diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js
index 1173686..6d0ee4a 100644
--- a/src/data/canvas-sizes.js
+++ b/src/data/canvas-sizes.js
@@ -5,10 +5,20 @@ async function fetchCanvasSizesData(pool) {
return { canvasSizes };
}
-async function fetchCanvasSizesPage(pool, page, pageSize) {
+async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
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',
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,
pageSize: pageSize
});
diff --git a/src/data/rss-feeds.js b/src/data/rss-feeds.js
index 123f50d..5a24df2 100644
--- a/src/data/rss-feeds.js
+++ b/src/data/rss-feeds.js
@@ -15,10 +15,22 @@ async function fetchRssFeedsData(pool) {
return { rssFeeds: rssFeeds };
}
-async function fetchRssFeedsPage(pool, page, pageSize) {
+async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
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',
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,
pageSize: pageSize
});
diff --git a/src/data/utils.js b/src/data/utils.js
index 0fd341c..056265f 100644
--- a/src/data/utils.js
+++ b/src/data/utils.js
@@ -27,23 +27,168 @@ function normalizePageNumber(value) {
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) {
const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim();
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 currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !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 totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages);
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 {
rows: rows || [],
@@ -77,6 +222,10 @@ module.exports = {
parseJsonSafe,
readFormArray,
normalizePageNumber,
+ normalizeSortDirection,
+ buildSortOrderClause,
+ findTopLevelOrderByIndex,
+ buildSearchFilter,
fetchPagedRows,
fetchDuplicateName
};
diff --git a/src/player/public/favicon.png b/src/player/public/favicon.png
index 39d265f..bfc6249 100644
Binary files a/src/player/public/favicon.png and b/src/player/public/favicon.png differ
diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js
index 8745a93..cb7c03d 100644
--- a/src/player/public/js/player-page-commands.js
+++ b/src/player/public/js/player-page-commands.js
@@ -320,7 +320,12 @@ function handleCommandMessage(rawMessage) {
}
return;
case 'pause':
- setPaused(!isPaused);
+ var desiredPause = normalizeBoolean(payload.paused);
+ if (desiredPause !== null) {
+ setPaused(desiredPause);
+ } else {
+ setPaused(!isPaused);
+ }
return;
case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout);
diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js
index f4fcc7b..0202f9d 100644
--- a/src/web/lib/dashboard-state.js
+++ b/src/web/lib/dashboard-state.js
@@ -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) {
const pool = options && options.pool;
const common = options && options.common;
@@ -82,11 +94,13 @@ function createDashboardStateService(options) {
}
});
- const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
- return Object.assign({}, screen, {
- player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
- });
- });
+ const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
+ .map(function (screen) {
+ return Object.assign({}, screen, {
+ player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
+ });
+ })
+ .sort(compareScreenNames);
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN;
diff --git a/src/web/lib/list-query.js b/src/web/lib/list-query.js
new file mode 100644
index 0000000..b639c39
--- /dev/null
+++ b/src/web/lib/list-query.js
@@ -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
+};
\ No newline at end of file
diff --git a/src/web/lib/pagination.js b/src/web/lib/pagination.js
index 3473701..49033e9 100644
--- a/src/web/lib/pagination.js
+++ b/src/web/lib/pagination.js
@@ -17,26 +17,61 @@ function buildQueryString(query) {
return queryString ? `?${queryString}` : '';
}
+function createPageEntry(pageNumber, currentPage, pageParam, queryState) {
+ const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
+
+ return {
+ number: pageNumber,
+ active: pageNumber === currentPage,
+ 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));
- let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2));
- let endPage = startPage + visiblePageCount - 1;
+ const firstPage = 1;
+ const lastPage = totalPages;
- if (endPage > totalPages) {
- endPage = totalPages;
- startPage = Math.max(1, endPage - visiblePageCount + 1);
+ 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) {
- const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
- pages.push({
- number: pageNumber,
- active: pageNumber === currentPage,
- url: buildQueryString(nextQuery)
- });
+ pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
}
+ if (endPage < lastPage - 1) {
+ pages.push(createEllipsisEntry());
+ }
+
+ pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState));
+
return pages;
}
@@ -51,6 +86,8 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
const previousQuery = 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 {
currentPage: safeCurrentPage,
@@ -59,8 +96,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
hasMultiplePages: totalPages > 1,
startItem: startIndex + 1,
endItem: endIndex,
+ hasFirst: safeCurrentPage > 1,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
+ hasLast: safeCurrentPage < totalPages,
+ firstUrl: buildQueryString(firstQuery),
+ lastUrl: buildQueryString(lastQuery),
previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery),
pages: pages,
diff --git a/src/web/lib/rbac-data.js b/src/web/lib/rbac-data.js
index e06c73a..5528aa3 100644
--- a/src/web/lib/rbac-data.js
+++ b/src/web/lib/rbac-data.js
@@ -28,7 +28,7 @@ async function fetchRoles(pool) {
return rows || [];
}
-async function fetchRolesPage(pool, page, pageSize) {
+async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, {
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,
@@ -36,6 +36,18 @@ async function fetchRolesPage(pool, page, pageSize) {
FROM roles r
ORDER BY r.name ASC`,
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,
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, {
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
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
ORDER BY u.id ASC`,
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,
pageSize: pageSize
});
diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css
index acd6867..0653930 100644
--- a/src/web/public/css/theme-custom.css
+++ b/src/web/public/css/theme-custom.css
@@ -267,7 +267,7 @@
}
.dashboard-actions-card .card-header,
-.dashboard-table-card .card-header {
+.dashboard-screen-card-shell .card-header {
padding-top: 1rem;
padding-bottom: 1rem;
}
@@ -309,6 +309,107 @@
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 {
display: flex;
align-items: flex-end;
diff --git a/src/web/public/favicon.png b/src/web/public/favicon.png
index 39d265f..bfc6249 100644
Binary files a/src/web/public/favicon.png and b/src/web/public/favicon.png differ
diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js
index 998c0f3..61d80c0 100644
--- a/src/web/public/js/admin/admin-page.js
+++ b/src/web/public/js/admin/admin-page.js
@@ -164,9 +164,6 @@
if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement);
}
- if (typeof window.initTablePaginations === 'function') {
- window.initTablePaginations(targetElement);
- }
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -201,7 +198,7 @@
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
- }).catch(function (_error) {
+ }).catch(function () {
// 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 = '' +
- '
' +
- '
' +
- '
' +
- '
';
- 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('Previous');
- for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
- pageButtons.push('' + pageNumber + '');
- }
- pageButtons.push('Next');
- 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) {
- 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;
- }
-
- 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();
- });
+ if (typeof window.initTableSearches === 'function') {
+ window.initTableSearches(root);
+ }
}
function createSlideThumbPlaceholder() {
@@ -841,10 +545,6 @@
});
}
- if (window.initSortableTables) {
- window.initSortableTables();
- }
-
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
@@ -854,19 +554,9 @@
initJsonTogglePanels();
initLocalDateTimes();
initTableSearches();
- initTablePaginations();
attachSlideThumbFallbacks(document);
window.initJsonTogglePanels = initJsonTogglePanels;
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.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
}());
diff --git a/src/web/public/js/admin/rbac-permissions.js b/src/web/public/js/admin/rbac-permissions.js
deleted file mode 100644
index a2f6138..0000000
--- a/src/web/public/js/admin/rbac-permissions.js
+++ /dev/null
@@ -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();
-}());
diff --git a/src/web/public/js/admin/system-status.js b/src/web/public/js/admin/system-status.js
deleted file mode 100644
index 7458fbd..0000000
--- a/src/web/public/js/admin/system-status.js
+++ /dev/null
@@ -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();
-}());
-
diff --git a/src/web/public/js/admin/theme-sidebar.js b/src/web/public/js/admin/theme-sidebar.js
deleted file mode 100644
index 76f1cd3..0000000
--- a/src/web/public/js/admin/theme-sidebar.js
+++ /dev/null
@@ -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();
-}());
diff --git a/src/web/public/js/admin/toast.js b/src/web/public/js/admin/toast.js
deleted file mode 100644
index 758e268..0000000
--- a/src/web/public/js/admin/toast.js
+++ /dev/null
@@ -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 = '';
- 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();
-}());
diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js
index e89c78c..624afce 100644
--- a/src/web/public/js/dashboard/dashboard-page.js
+++ b/src/web/public/js/dashboard/dashboard-page.js
@@ -180,18 +180,50 @@
].join('');
}
- function renderScreenRow(screen) {
+ function renderScreenTile(screen) {
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 [
- '',
- '| ' + escapeHtml(screen.name) + ' | ',
- '' + escapeHtml(screen.player_url || '') + ' | ',
- '' + escapeHtml(screen.playlist_name || '') + ' | ',
- '' + (clientCount ? ' ' + clientCount + ' connected ' : 'No clients connected.') + ' | ',
- '
'
+ '',
+ '',
+ '',
+ '- Playlist
- ' + playlistLabel + '
',
+ '- Connections
- ' + escapeHtml(connectionsLabel) + '
',
+ '
',
+ ''
].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) {
var clientCount = document.getElementById('dashboard-client-count');
var screenCount = document.getElementById('dashboard-screen-count');
@@ -280,36 +312,52 @@
tbody.removeChild(tbody.lastElementChild);
}
- window.applyTableSort(document.getElementById('dashboard-clients-table'));
}
- function updateScreenTable(state) {
- var table = document.getElementById('dashboard-screens-table');
- if (!table || !Array.isArray(state.screens)) {
+ function updateScreenGrid(state) {
+ var grid = document.getElementById('dashboard-screens-grid');
+ if (!grid || !Array.isArray(state.screens)) {
return;
}
- var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
- if (!tbody) {
+ var screens = state.screens.slice().sort(compareScreensByConnectedClients);
+
+ if (!screens.length) {
+ grid.innerHTML = 'No screens yet.
';
return;
}
- if (!state.screens.length) {
- tbody.innerHTML = '| No screens yet. |
';
- return;
- }
- tbody.innerHTML = state.screens.map(renderScreenRow).join('');
- window.applyTableSort(table);
+ grid.innerHTML = screens.map(renderScreenTile).join('');
}
function updateDashboardQuickActions(state) {
+ var pauseButton = document.getElementById('dashboard-pause-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;
}
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) {
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 = '' + 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 blackoutForm = blackoutButton.form;
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
@@ -331,7 +379,7 @@
return;
}
updateStats(state);
- updateScreenTable(state);
+ updateScreenGrid(state);
updateClientTable(state);
updateDashboardQuickActions(state);
}
diff --git a/src/web/public/js/settings/background-tasks.js b/src/web/public/js/settings/background-tasks.js
index 5dcc18a..69219b9 100644
--- a/src/web/public/js/settings/background-tasks.js
+++ b/src/web/public/js/settings/background-tasks.js
@@ -1,247 +1,16 @@
(function () {
- var REFRESH_INTERVAL_MS = 10000;
- 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') {
+ if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
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) {
+ try {
+ var url = new URL(window.location.href);
+ if (!url.searchParams.has('message')) {
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);
- });
+ url.searchParams.delete('message');
+ window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
+ } catch (_error) {
+ // Ignore URL cleanup failures.
}
-
- function stripMessageParameter() {
- try {
- var url = new URL(window.location.href);
- if (!url.searchParams.has('message')) {
- return;
- }
- url.searchParams.delete('message');
- window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
- } catch (_error) {
- // 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);
}());
\ No newline at end of file
diff --git a/src/web/public/js/slide-form.js b/src/web/public/js/slide-form.js
deleted file mode 100644
index ac39b02..0000000
--- a/src/web/public/js/slide-form.js
+++ /dev/null
@@ -1,1257 +0,0 @@
-import {
- Alignment,
- BlockQuote,
- Bold,
- ClassicEditor,
- Base64UploadAdapter,
- ClipboardPipeline,
- Essentials,
- FontBackgroundColor,
- FontColor,
- FontFamily,
- FontSize,
- Heading,
- HorizontalLine,
- Image,
- ImageBlockEditing,
- ImageCaption,
- ImageInlineEditing,
- ImageInsert,
- ImageInsertUI,
- ImageInsertViaUrl,
- ImageInsertViaUrlUI,
- ImageResize,
- ImageResizeEditing,
- ImageResizeHandles,
- ImageStyle,
- ImageTextAlternative,
- ImageUpload,
- Indent,
- IndentBlock,
- Italic,
- InputTextView,
- Plugin,
- Paragraph,
- Strikethrough,
- Subscript,
- Superscript,
- Underline
-} from '/assets/js/vendor/ckeditor5/ckeditor5.js';
-import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
-
-(function () {
- var DEFAULT_FONT_SIZE = 32;
-
- var dataElement = document.getElementById('slide-editor-data');
- if (!dataElement) {
- return;
- }
-
- var slideEditorData = {};
- try {
- slideEditorData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
- } catch (_error) {
- slideEditorData = {};
- }
-
- var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : [];
- var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
- var existingContent = slideEditorData.existingContent || {};
- var templateSelect = document.getElementById('template-select');
- var templateFields = document.getElementById('template-fields');
- var slideForm = document.getElementById('slide-form');
- var slideEditorShell = document.querySelector('.slide-editor-shell');
- var slideEditorSidebar = document.querySelector('.slide-editor-sidebar');
- var slidePreviewMeta = document.getElementById('slide-preview-meta');
- var slidePreviewStage = document.getElementById('slide-preview-stage');
- var slidePreviewCanvas = document.getElementById('slide-preview-canvas');
- var slidePreviewBackground = document.getElementById('slide-preview-background');
- var slidePreviewEmpty = document.getElementById('slide-preview-empty');
- var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
- var openPreviewPopupButton = document.getElementById('open-preview-popup');
- var editorInstances = new Map();
- var submitting = false;
- var previewRenderFrame = 0;
- var previewPopupWindow = null;
- var previewPopupRenderFrame = 0;
- var currentPreviewCanvasWidth = 0;
- var currentPreviewCanvasHeight = 0;
- var sidebarSyncFrame = 0;
- var sidebarTopOffset = 16;
- var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
-
- class FontSizeInputUI extends Plugin {
- static get pluginName() {
- return 'FontSizeInputUI';
- }
-
- init() {
- var editor = this.editor;
- var fontSizeCommand = editor.commands.get('fontSize');
- var plugin = this;
-
- if (!fontSizeCommand) {
- return;
- }
-
- editor.ui.componentFactory.add('fontSizeInput', function (locale) {
- var view = new InputTextView(locale);
-
- view.extendTemplate({
- attributes: {
- class: ['ck-font-size-input']
- }
- });
-
- view.set({
- placeholder: 'Font size',
- ariaLabel: 'Font size'
- });
-
- function getCurrentFontSizeValue() {
- var value = fontSizeCommand.value;
- if (value === null || value === undefined || value === '') {
- return String(DEFAULT_FONT_SIZE);
- }
-
- var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, '')));
- return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : '';
- }
-
- function syncValue() {
- if (view.element && document.activeElement === view.element) {
- return;
- }
-
- view.value = getCurrentFontSizeValue();
- }
-
- view.on('render', function () {
- if (!view.element) {
- return;
- }
-
- view.element.type = 'text';
- view.element.inputMode = 'numeric';
- view.element.autocomplete = 'off';
- view.element.spellcheck = false;
- view.element.style.width = '3.5em';
- syncValue();
- });
-
- function applyValue() {
- if (!view.element) {
- return;
- }
-
- var size = Math.max(1, Math.round(Number(view.element.value || 0)));
- if (!size) {
- return;
- }
-
- editor.execute('fontSize', { value: size + 'px' });
- }
-
- view.on('input', applyValue);
- view.on('blur', applyValue);
-
- view.on('keydown', function (_eventInfo, domEvent) {
- if (domEvent.key === 'Enter') {
- domEvent.preventDefault();
- if (view.element) {
- view.element.blur();
- }
- }
- });
-
- plugin.listenTo(editor.model.document.selection, 'change:range', syncValue);
- plugin.listenTo(fontSizeCommand, 'change:value', syncValue);
-
- syncValue();
-
- return view;
- });
- }
- }
-
- class ForcePlainTextPaste extends Plugin {
- static get pluginName() {
- return 'ForcePlainTextPaste';
- }
-
- static get requires() {
- return [ClipboardPipeline];
- }
-
- init() {
- var editor = this.editor;
- var clipboardPipeline = editor.plugins.get(ClipboardPipeline);
-
- this.listenTo(clipboardPipeline, 'inputTransformation', function (_event, data) {
- if (clipboardContainsImageContent(data.dataTransfer)) {
- return;
- }
-
- var plainText = data.dataTransfer.getData('text/plain');
- data.content = editor.data.processor.toView(plainTextToHtml(plainText));
- }, { priority: 'high' });
- }
- }
-
- function escapeHtml(value) {
- return String(value ?? '')
- .replace(/&/g, '&')
- .replace(//g, '>')
- .replace(/"/g, '"')
- .replace(/'/g, ''');
- }
-
- function plainTextToHtml(value) {
- var lines = String(value ?? '').replace(/\r\n?/g, '\n').split('\n');
- var paragraphs = [];
- var currentParagraph = [];
-
- lines.forEach(function (line) {
- if (line === '') {
- if (currentParagraph.length) {
- paragraphs.push('' + currentParagraph.join('
') + '
');
- currentParagraph = [];
- } else {
- paragraphs.push('');
- }
- return;
- }
-
- currentParagraph.push(escapeHtml(line));
- });
-
- if (currentParagraph.length) {
- paragraphs.push('' + currentParagraph.join('
') + '
');
- }
-
- return paragraphs.length ? paragraphs.join('') : '';
- }
-
- function clipboardContainsImageContent(dataTransfer) {
- if (!dataTransfer) {
- return false;
- }
-
- var files = Array.from(dataTransfer.files || []);
- if (files.some(function (file) {
- return file && String(file.type || '').indexOf('image/') === 0;
- })) {
- return true;
- }
-
- var html = dataTransfer.getData('text/html') || '';
- return /
]*\bclass=["'][^"']*\bimage\b/i.test(html);
- }
-
- function getTemplateById(id) {
- return templates.find(function (item) { return Number(item.id) === Number(id); }) || null;
- }
-
- function getCurrentRegionValue(region) {
- var current = existingContent[region.region_key];
- return current && current.value !== undefined ? current.value : '';
- }
-
- function normalizeEditorData(value) {
- return String(value || '');
- }
-
- function requestPreviewRender() {
- if (previewRenderFrame) {
- return;
- }
-
- previewRenderFrame = window.requestAnimationFrame(function () {
- previewRenderFrame = 0;
- renderPreview();
- });
- }
-
- function getTemplateFieldControls() {
- return templateFields ? templateFields.querySelectorAll('input, textarea, select') : [];
- }
-
- function sanitizePreviewHtml(html) {
- var container = document.createElement('div');
- container.innerHTML = String(html || '');
- return sanitizePreviewNode(container);
- }
-
- function sanitizePreviewNode(node) {
- var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul'];
- var output = '';
-
- Array.from(node.childNodes || []).forEach(function (child) {
- if (child.nodeType === Node.TEXT_NODE) {
- output += escapeHtml(child.textContent);
- return;
- }
-
- if (child.nodeType !== Node.ELEMENT_NODE) {
- return;
- }
-
- var name = String(child.tagName || '').toLowerCase();
- if (allowed.indexOf(name) === -1) {
- return;
- }
-
- if (name === 'br' || name === 'hr') {
- output += '<' + name + '>';
- return;
- }
-
- var attrs = sanitizePreviewElementAttributes(child, name);
- if (name === 'img') {
- output += '
';
- return;
- }
-
- output += '<' + name + attrs + '>' + sanitizePreviewNode(child) + '' + name + '>';
- });
-
- return output;
- }
-
- function sanitizePreviewElementAttributes(element, tagName) {
- var allowedAttributes = {
- a: ['href', 'title', 'target', 'rel', 'class', 'style'],
- blockquote: ['class', 'style'],
- div: ['class', 'style'],
- figure: ['class', 'style'],
- figcaption: ['class', 'style'],
- h1: ['class', 'style'],
- h2: ['class', 'style'],
- h3: ['class', 'style'],
- h4: ['class', 'style'],
- h5: ['class', 'style'],
- h6: ['class', 'style'],
- img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
- li: ['class', 'style'],
- ol: ['class', 'style', 'start'],
- p: ['class', 'style'],
- pre: ['class', 'style'],
- span: ['class', 'style'],
- table: ['class', 'style'],
- td: ['class', 'style', 'colspan', 'rowspan'],
- th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
- tr: ['class', 'style'],
- ul: ['class', 'style']
- };
- var allowed = allowedAttributes[tagName] || [];
- var attrs = [];
-
- Array.from(element.attributes || []).forEach(function (attribute) {
- var lowerKey = String(attribute.name || '').toLowerCase();
- if (allowed.indexOf(lowerKey) === -1) {
- return;
- }
-
- var value = String(attribute.value || '');
- if (tagName === 'img' && lowerKey === 'src') {
- value = sanitizeImageSrc(value);
- }
- if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(value)) {
- return;
- }
- if (tagName === 'img' && lowerKey === 'src' && !value) {
- return;
- }
- if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(value)) {
- return;
- }
- if (lowerKey === 'target') {
- var targetValue = value.trim();
- if (targetValue === '_blank') {
- attrs.push(' target="_blank"');
- if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
- attrs.push(' rel="noreferrer noopener"');
- }
- return;
- }
- }
- attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
- });
-
- return attrs.join('');
- }
-
- function sanitizeTagAttributes(tagName, attrText) {
- var allowedAttributes = {
- a: ['href', 'title', 'target', 'rel', 'class', 'style'],
- blockquote: ['class', 'style'],
- div: ['class', 'style'],
- figure: ['class', 'style'],
- figcaption: ['class', 'style'],
- h1: ['class', 'style'],
- h2: ['class', 'style'],
- h3: ['class', 'style'],
- h4: ['class', 'style'],
- h5: ['class', 'style'],
- h6: ['class', 'style'],
- img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'],
- li: ['class', 'style'],
- ol: ['class', 'style', 'start'],
- p: ['class', 'style'],
- pre: ['class', 'style'],
- span: ['class', 'style'],
- table: ['class', 'style'],
- td: ['class', 'style', 'colspan', 'rowspan'],
- th: ['class', 'style', 'colspan', 'rowspan', 'scope'],
- tr: ['class', 'style'],
- ul: ['class', 'style']
- };
- var allowed = allowedAttributes[tagName] || [];
- if (!allowed.length) {
- return '';
- }
-
- var attrs = [];
- attrText.replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) {
- var lowerKey = String(key || '').toLowerCase();
- if (allowed.indexOf(lowerKey) === -1) {
- return '';
- }
- var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : '';
- if (tagName === 'img' && lowerKey === 'src') {
- value = sanitizeImageSrc(value);
- }
- if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) {
- return '';
- }
- if (tagName === 'img' && lowerKey === 'src' && !value) {
- return '';
- }
- if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) {
- return '';
- }
- if (lowerKey === 'target') {
- var targetValue = String(value || '').trim();
- if (targetValue === '_blank') {
- attrs.push(' target="_blank"');
- if (attrs.indexOf(' rel="noreferrer noopener"') === -1) {
- attrs.push(' rel="noreferrer noopener"');
- }
- return '';
- }
- }
- attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"');
- return '';
- });
-
- return attrs.join('');
- }
-
- function sanitizeImageSrc(value) {
- var raw = String(value || '').trim();
- if (!raw) {
- return '';
- }
-
- if (/^(?:https?:|\/|\.\.?\/|\/\/)/i.test(raw)) {
- return raw;
- }
-
- if (/^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i.test(raw)) {
- return raw;
- }
-
- return '';
- }
-
- function renderPreviewListItem(item, tag) {
- if (item && typeof item === 'object') {
- var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : '');
- var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : [];
- var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderPreviewListItem(child, tag); }).join('') + '' + tag + '>' : '';
- return '' + sanitizePreviewHtml(content || '') + nested + '';
- }
- return '' + sanitizePreviewHtml(item || '') + '';
- }
-
- function renderPreviewTable(data) {
- var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : [];
- if (!rows.length) {
- return '';
- }
- var hasHeadings = Boolean(data.withHeadings);
- var body = rows.map(function (row, rowIndex) {
- var cells = Array.isArray(row) ? row : [];
- var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td';
- var cellAttrs = cellTag === 'th' ? ' scope="col"' : '';
- return '' + cells.map(function (cell) {
- return '<' + cellTag + cellAttrs + '>' + sanitizePreviewHtml(cell || '') + '' + cellTag + '>';
- }).join('') + '
';
- }).join('');
- return '';
- }
-
- function renderPreviewBlock(block) {
- if (!block || !block.type || !block.data) {
- return '';
- }
- if (block.type === 'header') {
- var level = Math.max(1, Math.min(6, Number(block.data.level || 2)));
- return '' + sanitizePreviewHtml(block.data.text || '') + '';
- }
- if (block.type === 'list') {
- var tag = block.data.style === 'ordered' ? 'ol' : 'ul';
- var items = Array.isArray(block.data.items) ? block.data.items : [];
- return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderPreviewListItem(item, tag); }).join('') + '' + tag + '>';
- }
- if (block.type === 'delimiter') {
- return '
';
- }
- if (block.type === 'code') {
- return '' + sanitizePreviewHtml(block.data.code || '') + '
';
- }
- if (block.type === 'table') {
- return renderPreviewTable(block.data);
- }
- if (block.type === 'paragraph') {
- return '' + sanitizePreviewHtml(block.data.text || '') + '
';
- }
- return '';
- }
-
- function renderPreviewText(value) {
- var raw = String(value || '');
- if (!raw) {
- return 'Empty text
';
- }
- return sanitizePreviewHtml(raw);
- }
-
- function renderPreviewTextRegion(region, value, style, scale) {
- var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : '';
- var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : '';
- var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : '';
- var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
- var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
- var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color;
- return '' + renderPreviewText(value) + '
';
- }
-
- function renderPreviewWebpageRegion(value) {
- var src = String(value || '').trim();
- if (!src) {
- return 'Webpage
';
- }
- return '';
- }
-
- function renderPreviewHtmlRegion(value) {
- var html = String(value || '').trim();
- if (!html) {
- return 'HTML
';
- }
- return '';
- }
-
- function destroyEditors() {
- editorInstances.forEach(function (editor) {
- if (editor && typeof editor.destroy === 'function') {
- editor.destroy();
- }
- });
- editorInstances.clear();
- }
-
- function getEditorHiddenInput(regionId) {
- var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
- return card ? card.querySelector('input[type="hidden"][name="region_text_' + regionId + '"]') : null;
- }
-
- function getEditorFontSizeHiddenInput(regionId) {
- var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
- return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null;
- }
-
- function normalizeFontSizeValue(value) {
- var size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, ''))));
- return size ? String(size) : '';
- }
-
- function getCurrentTextStyle(region) {
- var current = existingContent[region.region_key] || {};
- return {
- font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial',
- font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)),
- font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000'
- };
- }
-
- function getTextStyleFromCard(_card, region) {
- return getCurrentTextStyle(region);
- }
-
- function getSelectedTemplate() {
- return templates.find(function (item) { return Number(item.id) === Number(templateSelect.value); }) || null;
- }
-
- function clearImagePreviewUrls() {
- templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
- if (input.dataset.previewUrl) {
- URL.revokeObjectURL(input.dataset.previewUrl);
- delete input.dataset.previewUrl;
- }
- });
- }
-
- function getRegionBox(region, canvasWidth, canvasHeight) {
- return {
- left: Math.max(0, Math.round(Number(region.x || 0))),
- top: Math.max(0, Math.round(Number(region.y || 0))),
- width: Math.max(1, Math.round(Number(region.width || 0) || 1)),
- height: Math.max(1, Math.round(Number(region.height || 0) || 1))
- };
- }
-
- function getPreviewCanvasDimensions(template) {
- return {
- width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
- height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
- };
- }
-
- function buildPreviewPopupHtml() {
- return '' +
- '' +
- '' +
- '' +
- '' +
- 'Slide preview' +
- '' +
- '' +
- '' +
- '' +
- '' +
- '';
- }
-
- function syncPopupPreview() {
- if (!previewPopupWindow || previewPopupWindow.closed) {
- previewPopupWindow = null;
- return;
- }
-
- var popupDocument = previewPopupWindow.document;
- if (!popupDocument) {
- return;
- }
-
- var popupStage = popupDocument.getElementById('popup-preview-stage');
- var popupCanvas = popupDocument.getElementById('popup-preview-canvas');
- var template = getSelectedTemplate();
-
- if (!popupStage || !popupCanvas) {
- if (previewPopupRenderFrame) {
- cancelAnimationFrame(previewPopupRenderFrame);
- }
- previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview);
- return;
- }
-
- if (!template) {
- popupStage.style.backgroundColor = '#111111';
- popupCanvas.style.width = '';
- popupCanvas.style.height = '';
- popupCanvas.style.transform = '';
- popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
- var popupEmpty = popupCanvas.querySelector('#slide-preview-empty');
- var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay');
- var popupBackground = popupCanvas.querySelector('#slide-preview-background');
- if (popupBackground) {
- popupBackground.style.display = 'none';
- }
- if (popupEmpty) {
- popupEmpty.style.display = 'flex';
- popupEmpty.textContent = 'Select a template to preview the slide.';
- }
- if (popupOverlay) {
- popupOverlay.innerHTML = '';
- }
- return;
- }
-
- var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920));
- var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080));
- popupStage.style.backgroundColor = template.background_color || '#111111';
- popupCanvas.style.width = canvasWidth + 'px';
- popupCanvas.style.height = canvasHeight + 'px';
- popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
- var popupBackground = popupCanvas.querySelector('#slide-preview-background');
- var popupEmpty = popupCanvas.querySelector('#slide-preview-empty');
- var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay');
-
- if (popupBackground) {
- popupBackground.style.display = template.background_image_path ? 'block' : 'none';
- popupBackground.src = template.background_image_path || '';
- }
- if (popupEmpty) {
- popupEmpty.style.display = 'none';
- }
-
- var stageRect = popupStage.getBoundingClientRect();
- if (!stageRect.width || !stageRect.height) {
- if (previewPopupRenderFrame) {
- cancelAnimationFrame(previewPopupRenderFrame);
- }
- previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview);
- return;
- }
-
- var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
- popupCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
- }
-
- function openPreviewPopup() {
- if (previewPopupWindow && !previewPopupWindow.closed) {
- previewPopupWindow.focus();
- syncPopupPreview();
- return;
- }
-
- var popupWidth = 1280;
- var popupHeight = 900;
- var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2));
- var top = Math.max(0, Math.round((window.screen.height - popupHeight) / 2));
- var popupFeatures = [
- 'popup=yes',
- 'toolbar=no',
- 'location=no',
- 'menubar=no',
- 'directories=no',
- 'status=no',
- 'scrollbars=yes',
- 'resizable=yes',
- 'width=' + popupWidth,
- 'height=' + popupHeight,
- 'left=' + left,
- 'top=' + top
- ].join(',');
-
- previewPopupWindow = window.open('', 'slide-preview-popup', popupFeatures);
- if (!previewPopupWindow) {
- return;
- }
-
- previewPopupWindow.document.open();
- previewPopupWindow.document.write(buildPreviewPopupHtml());
- previewPopupWindow.document.close();
- previewPopupWindow.addEventListener('resize', syncPopupPreview);
- previewPopupWindow.focus();
- syncPopupPreview();
- }
-
- function syncSlideEditorSidebar() {
- sidebarSyncFrame = 0;
-
- if (!slideEditorShell || !slideEditorSidebar) {
- return;
- }
-
- if (window.matchMedia && window.matchMedia('(max-width: 1199.98px)').matches) {
- slideEditorSidebar.style.transform = '';
- return;
- }
-
- var shellRect = slideEditorShell.getBoundingClientRect();
- var sidebarHeight = slideEditorSidebar.offsetHeight;
- var shellTop = window.scrollY + shellRect.top;
- var shellBottom = shellTop + slideEditorShell.offsetHeight;
- var maxTranslate = Math.max(0, shellBottom - shellTop - sidebarHeight - sidebarTopOffset);
- var desiredTranslate = Math.max(0, Math.min(window.scrollY + sidebarTopOffset - shellTop, maxTranslate));
-
- slideEditorSidebar.style.transform = desiredTranslate ? 'translate3d(0, ' + desiredTranslate + 'px, 0)' : '';
- }
-
- function requestSidebarSync() {
- if (sidebarSyncFrame) {
- return;
- }
-
- sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar);
- }
-
- function renderPreview() {
- var template = getSelectedTemplate();
- if (!template) {
- slidePreviewMeta.textContent = '--';
- slidePreviewBackground.style.display = 'none';
- slidePreviewEmpty.style.display = 'flex';
- slidePreviewEmpty.textContent = 'Select a template to preview the slide.';
- slidePreviewCanvas.style.width = '';
- slidePreviewCanvas.style.height = '';
- slidePreviewOverlay.innerHTML = '';
- slidePreviewOverlay.style.width = '';
- slidePreviewOverlay.style.height = '';
- slidePreviewOverlay.style.transform = '';
- slidePreviewStage.style.aspectRatio = '16 / 9';
- slidePreviewStage.style.backgroundColor = '#111111';
- return;
- }
-
- var canvasSize = getPreviewCanvasDimensions(template);
- var canvasWidth = canvasSize.width;
- var canvasHeight = canvasSize.height;
- currentPreviewCanvasWidth = canvasWidth;
- currentPreviewCanvasHeight = canvasHeight;
- slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
- slidePreviewStage.style.backgroundColor = template.background_color || '#111111';
- slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
- if (!(template.regions || []).length) {
- slidePreviewEmpty.style.display = 'flex';
- slidePreviewEmpty.textContent = 'This template has no regions.';
- slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none';
- slidePreviewBackground.src = template.background_image_path || '';
- slidePreviewCanvas.style.width = canvasWidth + 'px';
- slidePreviewCanvas.style.height = canvasHeight + 'px';
- slidePreviewCanvas.style.transform = '';
- slidePreviewOverlay.innerHTML = '';
- slidePreviewOverlay.style.width = '';
- slidePreviewOverlay.style.height = '';
- slidePreviewOverlay.style.transform = '';
- return;
- }
-
- slidePreviewEmpty.style.display = 'none';
- slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none';
- slidePreviewBackground.src = template.background_image_path || '';
-
- var stageRect = slidePreviewStage.getBoundingClientRect();
- if (!stageRect.width || !stageRect.height) {
- if (previewRenderFrame) {
- cancelAnimationFrame(previewRenderFrame);
- }
- previewRenderFrame = requestAnimationFrame(function () {
- previewRenderFrame = 0;
- renderPreview();
- });
- return;
- }
- var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
- slidePreviewCanvas.style.width = canvasWidth + 'px';
- slidePreviewCanvas.style.height = canvasHeight + 'px';
- slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
- slidePreviewCanvas.style.transformOrigin = 'top left';
- var regions = template.regions || [];
- slidePreviewOverlay.innerHTML = regions.map(function (region) {
- var box = getRegionBox(region, canvasWidth, canvasHeight);
- var card = templateFields.querySelector('[data-region-id="' + region.id + '"]');
- var hiddenInput = card && card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]');
- var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]');
- var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
- var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]');
- var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
- var value = region.region_type === 'image'
- ? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
- : region.region_type === 'webpage'
- ? (webpageInput ? webpageInput.value : '')
- : region.region_type === 'html'
- ? (htmlInput ? htmlInput.value : '')
- : (hiddenInput ? hiddenInput.value : '');
- var style = region.region_type === 'image'
- ? null
- : region.region_type === 'webpage'
- ? null
- : region.region_type === 'html'
- ? null
- : getTextStyleFromCard(card, region);
- var content = region.region_type === 'image'
- ? (value ? '
' : 'Image
')
- : region.region_type === 'webpage'
- ? renderPreviewWebpageRegion(value)
- : region.region_type === 'html'
- ? renderPreviewHtmlRegion(value)
- : renderPreviewTextRegion(region, value, style, scale);
- var selected = region.region_type === 'image'
- ? ' slide-preview-image-region'
- : region.region_type === 'webpage'
- ? ' slide-preview-webpage-region'
- : region.region_type === 'html'
- ? ' slide-preview-html-region'
- : ' slide-preview-text-region';
- return '' + content + '
';
- }).join('');
-
- syncPopupPreview();
- }
-
- function renderTextRegion(region) {
- var current = getCurrentRegionValue(region);
- return '' +
- '';
- }
-
- function renderWebpageRegion(region) {
- var current = getCurrentRegionValue(region);
- return '' +
- '';
- }
-
- function renderHtmlRegion(region) {
- var current = getCurrentRegionValue(region);
- return '' +
- '';
- }
-
- function renderImageRegion(region) {
- var current = getCurrentRegionValue(region);
- return '' +
- '';
- }
-
- function renderTemplate() {
- templateSelectorLock.reset();
- clearImagePreviewUrls();
- destroyEditors();
- var template = getTemplateById(templateSelect.value);
- if (!template) {
- templateFields.innerHTML = '';
- requestPreviewRender();
- return;
- }
-
- templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) {
- if (region.region_type === 'image') {
- return renderImageRegion(region);
- }
- if (region.region_type === 'webpage') {
- return renderWebpageRegion(region);
- }
- if (region.region_type === 'html') {
- return renderHtmlRegion(region);
- }
- return renderTextRegion(region);
- }).join('') : 'This template has no regions.
';
-
- templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) {
- var regionId = holder.getAttribute('data-region-id');
- var source = holder.querySelector('.ckeditor-source');
- var hidden = getEditorHiddenInput(regionId);
- var editorConfig = {
- licenseKey: 'GPL',
- placeholder: 'Write content...',
- toolbar: {
- items: [
- 'undo',
- 'redo',
- '|',
- 'fontSizeInput',
- 'fontFamily',
- 'fontColor',
- 'fontBackgroundColor',
- '|',
- 'bold',
- 'italic',
- 'underline',
- 'strikethrough',
- 'subscript',
- 'superscript',
- '|',
- 'horizontalLine',
- 'blockQuote',
- '|',
- 'alignment',
- '|',
- 'outdent',
- 'indent',
- '|',
- 'imageInsert',
- 'imageInsertViaUrl',
- 'imageStyle:block',
- 'imageStyle:inline',
- 'imageTextAlternative',
- 'imageResize'
- ],
- shouldNotGroupWhenFull: false
- },
- plugins: [
- Alignment,
- Base64UploadAdapter,
- BlockQuote,
- Bold,
- Essentials,
- ForcePlainTextPaste,
- FontBackgroundColor,
- FontColor,
- FontFamily,
- FontSize,
- FontSizeInputUI,
- HorizontalLine,
- Indent,
- IndentBlock,
- Italic,
- Image,
- ImageBlockEditing,
- ImageCaption,
- ImageInlineEditing,
- ImageInsert,
- ImageInsertUI,
- ImageInsertViaUrl,
- ImageInsertViaUrlUI,
- ImageResize,
- ImageResizeEditing,
- ImageResizeHandles,
- ImageStyle,
- ImageTextAlternative,
- ImageUpload,
- Paragraph,
- Strikethrough,
- Subscript,
- Superscript,
- Underline
- ],
- fontFamily: {
- supportAllValues: true
- },
- fontSize: {
- options: [20, 24, 28, 32, 36, 40, 44],
- supportAllValues: true
- },
- initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : ''))
- };
-
- ClassicEditor.create(source || holder, editorConfig).then(function (editor) {
- editorInstances.set(regionId, editor);
- var fontSizeHidden = getEditorFontSizeHiddenInput(regionId);
- var fontSizeCommand = editor.commands.get('fontSize');
- if (hidden) {
- hidden.value = editor.getData();
- }
- if (fontSizeHidden && fontSizeCommand) {
- fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
- fontSizeCommand.on('change:value', function () {
- fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value;
- });
- }
- editor.model.document.on('change:data', function () {
- var instance = editorInstances.get(regionId);
- if (instance !== editor) {
- return;
- }
- if (hidden) {
- hidden.value = editor.getData();
- }
- templateSelectorLock.markEdited();
- requestPreviewRender();
- });
- requestPreviewRender();
- }).catch(function (error) {
- console.error('Failed to initialize CKEditor.', error);
- });
- });
-
- templateFields.querySelectorAll('[data-toggle-editor-height]').forEach(function (button) {
- button.addEventListener('click', function () {
- var regionId = button.getAttribute('data-toggle-editor-height');
- var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
- if (!card) {
- return;
- }
-
- var expanded = card.classList.toggle('is-expanded');
- button.textContent = expanded ? 'Collapse' : 'Expand';
- });
- });
-
- templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
- input.addEventListener('change', function () {
- if (input.dataset.previewUrl) {
- URL.revokeObjectURL(input.dataset.previewUrl);
- delete input.dataset.previewUrl;
- }
- if (input.files && input.files.length) {
- input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
- }
- templateSelectorLock.markEdited();
- requestPreviewRender();
- });
- });
- window.requestAnimationFrame(function () {
- templateSelectorLock.arm();
- });
- requestPreviewRender();
- }
-
- slideForm.addEventListener('submit', async function (event) {
- if (submitting) {
- return;
- }
- event.preventDefault();
- var submitterValue = event.submitter && event.submitter.name === 'save_action'
- ? String(event.submitter.value || '').trim().toLowerCase()
- : '';
- var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) {
- var regionId = holder.getAttribute('data-region-id');
- var editor = editorInstances.get(regionId);
- var hidden = getEditorHiddenInput(regionId);
- if (!editor || !hidden) {
- return Promise.resolve();
- }
- hidden.value = editor.getData();
- return Promise.resolve();
- });
- submitting = true;
- try {
- await Promise.all(saves);
- var formData = new FormData(slideForm);
- if (event.submitter && event.submitter.name) {
- formData.set(event.submitter.name, event.submitter.value || '');
- }
- var response = await fetch(slideForm.action, {
- method: (slideForm.method || 'POST').toUpperCase(),
- headers: {
- 'X-Requested-With': 'XMLHttpRequest',
- 'Accept': 'text/html, application/json, text/plain, */*'
- },
- body: formData,
- credentials: 'same-origin'
- });
-
- if (!response.ok) {
- throw new Error(await response.text() || 'Unable to save slide.');
- }
-
- if (submitterValue === 'close' || submitterValue === 'new') {
- var redirectUrl = submitterValue === 'close'
- ? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action)
- : String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action);
- window.location.replace(redirectUrl);
- return;
- }
-
- var responseText = await response.text();
- var savedMessage = '';
- try {
- var doc = new DOMParser().parseFromString(responseText || '', 'text/html');
- var toastBody = doc.querySelector('.toast-body');
- if (toastBody && toastBody.textContent) {
- savedMessage = toastBody.textContent.trim();
- }
- } catch (_error) {
- savedMessage = '';
- }
-
- showToast(savedMessage || 'Saved slide.', 'success');
- slideForm.dataset.dirty = 'false';
- } finally {
- submitting = false;
- }
- });
-
- slideForm.addEventListener('input', function () {
- slideForm.dataset.dirty = 'true';
- }, true);
-
- slideForm.addEventListener('change', function () {
- slideForm.dataset.dirty = 'true';
- }, true);
-
- if (openPreviewPopupButton) {
- openPreviewPopupButton.addEventListener('click', function () {
- openPreviewPopup();
- });
- }
-
- templateSelect.addEventListener('change', renderTemplate);
- templateFields.addEventListener('change', function () {
- templateSelectorLock.markEdited();
- requestPreviewRender();
- });
- templateFields.addEventListener('input', function () {
- templateSelectorLock.markEdited();
- requestPreviewRender();
- });
- if (existingTemplateId) {
- templateSelect.value = String(existingTemplateId);
- }
- renderTemplate();
- if (window.ResizeObserver && slidePreviewStage) {
- var previewResizeObserver = new ResizeObserver(function () {
- requestPreviewRender();
- });
- previewResizeObserver.observe(slidePreviewStage);
- }
- requestSidebarSync();
- window.addEventListener('scroll', requestSidebarSync, { passive: true });
- window.addEventListener('resize', requestPreviewRender);
- window.addEventListener('resize', requestSidebarSync);
- window.addEventListener('beforeunload', function () {
- if (previewPopupWindow && !previewPopupWindow.closed) {
- previewPopupWindow.close();
- }
- });
-})();
diff --git a/src/web/public/js/table-sort.js b/src/web/public/js/table-sort.js
deleted file mode 100644
index 3c80de0..0000000
--- a/src/web/public/js/table-sort.js
+++ /dev/null
@@ -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;
-}());
\ No newline at end of file
diff --git a/src/web/public/js/table/table-search.js b/src/web/public/js/table/table-search.js
new file mode 100644
index 0000000..fd6fdb7
--- /dev/null
+++ b/src/web/public/js/table/table-search.js
@@ -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;
+}());
\ No newline at end of file
diff --git a/src/web/public/js/table/table-sort.js b/src/web/public/js/table/table-sort.js
new file mode 100644
index 0000000..aa5196d
--- /dev/null
+++ b/src/web/public/js/table/table-sort.js
@@ -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();
+}());
\ No newline at end of file
diff --git a/src/web/public/js/theme-sidebar.js b/src/web/public/js/theme-sidebar.js
deleted file mode 100644
index f4704a5..0000000
--- a/src/web/public/js/theme-sidebar.js
+++ /dev/null
@@ -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();
-}());
\ No newline at end of file
diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js
index a1a593c..602dead 100644
--- a/src/web/routes/admin/content.js
+++ b/src/web/routes/admin/content.js
@@ -208,10 +208,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
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({
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));
} catch (error) {
next(error);
@@ -413,10 +416,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
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({
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));
} catch (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) {
try {
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({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/admin/data-sources.js b/src/web/routes/admin/data-sources.js
index f232ae5..5e6d6a0 100644
--- a/src/web/routes/admin/data-sources.js
+++ b/src/web/routes/admin/data-sources.js
@@ -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) {
try {
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) {
return Object.assign({}, apiSource, {
intervalLabel: apiSource.update_interval_unit === 'seconds'
@@ -211,7 +214,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
});
res.send(pages.renderApiSourcesPage({
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));
} catch (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) {
try {
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({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js
index 6a22de8..9f82970 100644
--- a/src/web/routes/admin/manage.js
+++ b/src/web/routes/admin/manage.js
@@ -36,12 +36,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout
: req.query.blackout;
+ const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused')
+ ? req.body.paused
+ : req.query.paused;
if (!command) {
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' });
}
@@ -56,7 +59,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
command: 'blackout',
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
}
- : 'reload';
+ : command === 'pause'
+ ? {
+ command: 'pause',
+ paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false
+ }
+ : 'reload';
const sentCount = await notifyPlayerScreens(slugs, payload);
await broadcastDashboardState();
@@ -65,7 +73,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
ok: true,
command: command,
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) {
next(error);
diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js
index 766e41c..7b0fd64 100644
--- a/src/web/routes/admin/rbac.js
+++ b/src/web/routes/admin/rbac.js
@@ -162,10 +162,13 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
try {
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({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/admin/users.js b/src/web/routes/admin/users.js
index 9a4ed2e..412b9b7 100644
--- a/src/web/routes/admin/users.js
+++ b/src/web/routes/admin/users.js
@@ -57,7 +57,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
try {
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) {
return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
@@ -68,7 +71,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
});
res.send(pages.renderUsersPage({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/settings/background-tasks-page.js b/src/web/routes/settings/background-tasks-page.js
index a352c90..1939b20 100644
--- a/src/web/routes/settings/background-tasks-page.js
+++ b/src/web/routes/settings/background-tasks-page.js
@@ -1,10 +1,11 @@
const { renderView } = require('../../view');
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 QUEUE_PAGE_URL = '/settings/background-tasks';
-const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled';
+const QUEUE_PAGE_URL = '/settings/tasks-background';
+const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
function formatIntervalLabel(intervalMs) {
const value = Math.max(1, Number(intervalMs) || 0);
@@ -19,11 +20,6 @@ function formatIntervalLabel(intervalMs) {
return `${value} ms`;
}
-function parsePageNumber(value) {
- const pageNumber = Math.floor(Number(value) || 1);
- return Math.max(1, pageNumber);
-}
-
function normalizeSourceFilter(sourceType, sourceId) {
const normalizedSourceType = String(sourceType || '').trim();
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) {
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
return true;
@@ -89,6 +43,8 @@ function taskMatchesFilters(task, sourceFilter) {
return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId;
}
+const taskMatchesSearch = createSearchMatcher;
+
function formatStatusLabel(status) {
const normalized = String(status || '').trim().toLowerCase();
if (!normalized) {
@@ -133,25 +89,61 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
- const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
+ const sort = String(data && data.sort || '').trim();
+ const direction = normalizeSortDirection(data && data.direction);
const queryState = {
page: parsePageNumber(data && data.page),
- recurringPage: parsePageNumber(data && data.recurringPage),
sourceType: '',
- sourceId: ''
+ sourceId: '',
+ search: String(data && data.search || '').trim(),
+ sort: sort,
+ direction: direction
};
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
queryState.sourceType = sourceFilter.sourceType;
queryState.sourceId = sourceFilter.sourceId;
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 recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
- const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
- const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
+ const sortedTasks = sortRows(filteredTasks, function (task) {
+ if (sort === 'task') {
+ return task && task.title;
+ }
+ 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 = [
{
status: 'queued',
@@ -199,16 +191,14 @@ function buildQueuePageViewModel(data, message, currentUser) {
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
- stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true,
tasks: visibleTasksWithSources,
summary: summary,
statusCards: statusCards,
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
- clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
+ clearFiltersUrl: QUEUE_PAGE_URL,
hasActiveFilters: hasActiveFilters,
pagination: pagination,
- recurringPagination: recurringPagination,
canManage: canManage,
canClearFinished: tasks.some(function (task) {
return task.status !== 'queued' && task.status !== 'running';
@@ -220,6 +210,47 @@ function buildScheduledPageViewModel(data, message, currentUser) {
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
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', {
title: 'Scheduled tasks',
@@ -227,17 +258,16 @@ function buildScheduledPageViewModel(data, message, currentUser) {
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
- stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true,
- recurringTasks: recurringTasks.map(function (task) {
+ recurringTasks: sortedRecurringTasks.map(function (task) {
return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs)
});
}),
summary: summary,
canManage: canManage,
- scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0),
- recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) })
+ scheduledCount: Number(summary.scheduledCount || filteredRecurringTasks.length || 0),
+ recurringPagination: buildPagination(sortedRecurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage), search: search, sort: sort, direction: direction })
});
}
diff --git a/src/web/routes/settings/background-tasks.js b/src/web/routes/settings/background-tasks.js
index 1d58ed8..b9c2479 100644
--- a/src/web/routes/settings/background-tasks.js
+++ b/src/web/routes/settings/background-tasks.js
@@ -42,65 +42,18 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
}
- function buildQueueVersion(tasks, recurringTasks, summary) {
- 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) {
+ app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
- const version = buildQueueVersion(tasks, recurringTasks, summary);
- 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));
+ 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));
});
- app.get('/settings/background-tasks/scheduled', requireScheduledSettingsAccess(), function (req, res) {
+ app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
- const version = buildQueueVersion(tasks, recurringTasks, summary);
- 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
- });
+ 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));
});
function requireSettingsManageAccess() {
@@ -120,22 +73,7 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
};
}
- app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) {
- 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) {
+ function requireRecurringTaskAccess(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
@@ -148,8 +86,25 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
}
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());
- 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.'));
});
};
diff --git a/src/web/routes/signage/canvas-sizes/routes.js b/src/web/routes/signage/canvas-sizes/routes.js
index 9ef7f93..af15427 100644
--- a/src/web/routes/signage/canvas-sizes/routes.js
+++ b/src/web/routes/signage/canvas-sizes/routes.js
@@ -10,10 +10,13 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
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({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/signage/clients/routes.js b/src/web/routes/signage/clients/routes.js
index fb91739..e28b41a 100644
--- a/src/web/routes/signage/clients/routes.js
+++ b/src/web/routes/signage/clients/routes.js
@@ -1,22 +1,65 @@
module.exports = function registerClientsRoutes(app, deps) {
const pool = deps.pool;
+ const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const { buildPagination } = require('../../../lib/pagination');
+ const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
const requirePermission = deps.requirePermission;
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) {
try {
const data = await buildDashboardState(pool);
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 clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE);
+ const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
res.send(pages.renderConnectedClientsPage({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/signage/playlists/routes.js b/src/web/routes/signage/playlists/routes.js
index c69a140..a73ebc2 100644
--- a/src/web/routes/signage/playlists/routes.js
+++ b/src/web/routes/signage/playlists/routes.js
@@ -17,6 +17,9 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
try {
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) {
const data = await common.fetchAdminData(pool);
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));
}
- 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({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/signage/screens/routes.js b/src/web/routes/signage/screens/routes.js
index 56f20be..83b827c 100644
--- a/src/web/routes/signage/screens/routes.js
+++ b/src/web/routes/signage/screens/routes.js
@@ -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) {
try {
if (req.query.edit) {
- const data = await buildDashboardState(pool);
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
if (!screen) {
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));
}
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({
- screens: data.screens || [],
- pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages')
+ screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
+ 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));
} catch (error) {
next(error);
diff --git a/src/web/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js
index aa52f43..1f6258f 100644
--- a/src/web/routes/signage/slides/routes.js
+++ b/src/web/routes/signage/slides/routes.js
@@ -10,10 +10,13 @@ module.exports = function registerSlidesRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
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({
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));
} catch (error) {
next(error);
diff --git a/src/web/routes/signage/templates/routes.js b/src/web/routes/signage/templates/routes.js
index df74118..6074aa7 100644
--- a/src/web/routes/signage/templates/routes.js
+++ b/src/web/routes/signage/templates/routes.js
@@ -10,10 +10,13 @@ module.exports = function registerTemplatesRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
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({
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));
} catch (error) {
next(error);
diff --git a/src/web/view.js b/src/web/view.js
index 991d292..d115e81 100644
--- a/src/web/view.js
+++ b/src/web/view.js
@@ -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('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) {
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
diff --git a/src/web/views/data-sources/api-sources/list.hbs b/src/web/views/data-sources/api-sources/list.hbs
index c0f3870..2b31e91 100644
--- a/src/web/views/data-sources/api-sources/list.hbs
+++ b/src/web/views/data-sources/api-sources/list.hbs
@@ -22,11 +22,11 @@
- | Name |
- API URL |
- Refresh interval |
- Last pulled |
- Last response |
+ Name |
+ API URL |
+ Refresh interval |
+ Last pulled |
+ Last response |
Actions |
diff --git a/src/web/views/data-sources/rss-feeds/list.hbs b/src/web/views/data-sources/rss-feeds/list.hbs
index 83970c2..ba89259 100644
--- a/src/web/views/data-sources/rss-feeds/list.hbs
+++ b/src/web/views/data-sources/rss-feeds/list.hbs
@@ -22,10 +22,10 @@
- | Name |
- Feed URL |
- Refresh interval |
- Items pulled |
+ Name |
+ Feed URL |
+ Refresh interval |
+ Items pulled |
Actions |
diff --git a/src/web/views/settings/rbac/list.hbs b/src/web/views/settings/rbac/list.hbs
index 2f7eec1..026a22c 100644
--- a/src/web/views/settings/rbac/list.hbs
+++ b/src/web/views/settings/rbac/list.hbs
@@ -24,10 +24,10 @@
- | Role |
- Description |
- Users |
- Permissions |
+ Role |
+ Description |
+ Users |
+ Permissions |
Actions |
diff --git a/src/web/views/settings/tasks-background/index.hbs b/src/web/views/settings/tasks-background/index.hbs
index f6f6831..32ca035 100644
--- a/src/web/views/settings/tasks-background/index.hbs
+++ b/src/web/views/settings/tasks-background/index.hbs
@@ -1,5 +1,3 @@
-
-
- {{#if pagination.hasMultiplePages}}
-
- {{/if}}
+ {{> table-pagination pagination=pagination basePath="/settings/tasks-background"}}
diff --git a/src/web/views/settings/tasks-scheduled/index.hbs b/src/web/views/settings/tasks-scheduled/index.hbs
index dc94994..68822e2 100644
--- a/src/web/views/settings/tasks-scheduled/index.hbs
+++ b/src/web/views/settings/tasks-scheduled/index.hbs
@@ -1,5 +1,3 @@
-
-