Compare commits

...
4 Commits
68 changed files with 1977 additions and 4640 deletions
+23 -7
View File
@@ -6,6 +6,29 @@ All notable changes to this project will be documented in this file.
- No unreleased changes recorded yet. - 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
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
### Fixed
- Template editing now blocks removal of regions that are still referenced by slides.
## 1.5.11 - 2026-07-26 ## 1.5.11 - 2026-07-26
### Added ### Added
@@ -47,13 +70,6 @@ All notable changes to this project will be documented in this file.
### Fixed ### Fixed
- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
## 1.5.7 - 2026-07-25
### Fixed
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images. - Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
## 1.5.6 - 2026-07-25 ## 1.5.6 - 2026-07-25
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.11", "version": "1.5.13",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.11", "version": "1.5.13",
"dependencies": { "dependencies": {
"@sparticuz/chromium": "^137.0.0", "@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3", "bootstrap-icons": "1.11.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "pulse-signage", "name": "pulse-signage",
"version": "1.5.11", "version": "1.5.13",
"private": false, "private": false,
"description": "Pulse Signage application with MySQL and media storage", "description": "Pulse Signage application with MySQL and media storage",
"repository": { "repository": {
+15
View File
@@ -2,6 +2,18 @@ const db = require('./db');
const data = require('./data'); const data = require('./data');
const player = require('./player/render'); const player = require('./player/render');
function getSearchQuery(req) {
return String(req && req.query && req.query.search || '').trim();
}
function getSortQuery(req) {
return String(req && req.query && req.query.sort || '').trim();
}
function getSortDirectionQuery(req) {
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
module.exports = { module.exports = {
createPool: db.createPool, createPool: db.createPool,
ensureSchema: db.ensureSchema, ensureSchema: db.ensureSchema,
@@ -14,6 +26,9 @@ module.exports = {
fetchTemplatesPage: data.fetchTemplatesPage, fetchTemplatesPage: data.fetchTemplatesPage,
fetchCanvasSizesPage: data.fetchCanvasSizesPage, fetchCanvasSizesPage: data.fetchCanvasSizesPage,
fetchScreensPage: data.fetchScreensPage, fetchScreensPage: data.fetchScreensPage,
getSearchQuery: getSearchQuery,
getSortQuery: getSortQuery,
getSortDirectionQuery: getSortDirectionQuery,
fetchPlaylistById: data.fetchPlaylistById, fetchPlaylistById: data.fetchPlaylistById,
fetchApiSourcesData: data.fetchApiSourcesData, fetchApiSourcesData: data.fetchApiSourcesData,
fetchApiSourcesPage: data.fetchApiSourcesPage, fetchApiSourcesPage: data.fetchApiSourcesPage,
+61 -6
View File
@@ -35,13 +35,23 @@ async function fetchAdminData(pool) {
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides }; return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
} }
async function fetchPlaylistsPage(pool, page, pageSize) { async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by, selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by,
(SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count (SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count
FROM playlists p FROM playlists p
ORDER BY p.id DESC`, ORDER BY p.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM playlists', countSql: 'SELECT COUNT(*) AS count FROM playlists',
searchColumns: ['p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'p.name',
slides: 'slide_count',
created: 'p.created_at',
modified: 'p.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -49,7 +59,7 @@ async function fetchPlaylistsPage(pool, page, pageSize) {
return Object.assign({ playlists: paged.rows }, paged); return Object.assign({ playlists: paged.rows }, paged);
} }
async function fetchSlidesPage(pool, page, pageSize) { async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s FROM slides s
@@ -57,6 +67,16 @@ async function fetchSlidesPage(pool, page, pageSize) {
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC`, ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slides', countSql: 'SELECT COUNT(*) AS count FROM slides',
searchColumns: ['s.title', 's.body', 's.media_path', 'st.name', 's.content_json'],
searchTerm: searchTerm,
sortColumns: {
title: 's.title',
template: 'st.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -64,15 +84,28 @@ async function fetchSlidesPage(pool, page, pageSize) {
return Object.assign({ slides: paged.rows }, paged); return Object.assign({ slides: paged.rows }, paged);
} }
async function fetchTemplatesPage(pool, page, pageSize) { async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count (SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
FROM slide_templates st FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC`, ORDER BY st.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM slide_templates', countSql: 'SELECT COUNT(*) AS count FROM slide_templates',
searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'],
searchTerm: searchTerm,
sortColumns: {
name: 'st.name',
canvas: ['cs.width', 'cs.height', 'cs.name'],
regions: 'region_count',
slides: 'slide_count',
created: 'st.created_at',
modified: 'st.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -80,13 +113,24 @@ async function fetchTemplatesPage(pool, page, pageSize) {
return Object.assign({ templates: paged.rows }, paged); return Object.assign({ templates: paged.rows }, paged);
} }
async function fetchCanvasSizesPage(pool, page, pageSize) { async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by, selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by,
(SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count (SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count
FROM canvas_sizes FROM canvas_sizes
ORDER BY width ASC, height ASC, name ASC`, ORDER BY width ASC, height ASC, name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
templates: 'template_count',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -94,13 +138,24 @@ async function fetchCanvasSizesPage(pool, page, pageSize) {
return Object.assign({ canvasSizes: paged.rows }, paged); return Object.assign({ canvasSizes: paged.rows }, paged);
} }
async function fetchScreensPage(pool, page, pageSize) { async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name
FROM screens s FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id LEFT JOIN playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC`, ORDER BY s.id DESC`,
countSql: 'SELECT COUNT(*) AS count FROM screens', countSql: 'SELECT COUNT(*) AS count FROM screens',
searchColumns: ['s.name', 's.slug', 'p.name'],
searchTerm: searchTerm,
sortColumns: {
name: 's.name',
url: 's.slug',
playlist: 'p.name',
created: 's.created_at',
modified: 's.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
+14 -1
View File
@@ -15,10 +15,23 @@ async function fetchApiSourcesData(pool) {
return { apiSources: apiSources }; return { apiSources: apiSources };
} }
async function fetchApiSourcesPage(pool, page, pageSize) { async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC', selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM api_sources', countSql: 'SELECT COUNT(*) AS count FROM api_sources',
searchColumns: ['name', 'api_url', 'last_pull_error'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'api_url',
interval: ['update_interval_value', 'update_interval_unit'],
last_pulled: 'last_pulled_at',
last_response: 'last_response_status',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
+11 -1
View File
@@ -5,10 +5,20 @@ async function fetchCanvasSizesData(pool) {
return { canvasSizes }; return { canvasSizes };
} }
async function fetchCanvasSizesPage(pool, page, pageSize) { async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC', selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC',
countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes',
searchColumns: ['name', 'width', 'height'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
dimensions: ['width', 'height', 'name'],
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
+13 -1
View File
@@ -15,10 +15,22 @@ async function fetchRssFeedsData(pool) {
return { rssFeeds: rssFeeds }; return { rssFeeds: rssFeeds };
} }
async function fetchRssFeedsPage(pool, page, pageSize) { async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC', selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC',
countSql: 'SELECT COUNT(*) AS count FROM rss_feeds', countSql: 'SELECT COUNT(*) AS count FROM rss_feeds',
searchColumns: ['name', 'feed_url'],
searchTerm: searchTerm,
sortColumns: {
name: 'name',
url: 'feed_url',
interval: ['update_interval_value', 'update_interval_unit'],
items: 'item_limit',
created: 'created_at',
modified: 'modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
+151 -2
View File
@@ -27,23 +27,168 @@ function normalizePageNumber(value) {
return Math.max(1, pageNumber); return Math.max(1, pageNumber);
} }
function escapeLikeValue(value) {
return String(value || '').replace(/[\\%_]/g, '\\$&');
}
function normalizeSortDirection(value) {
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
}
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedSortDirection = normalizeSortDirection(sortDirection);
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
if (!sortExpression) {
return {
clause: '',
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
const orderBySql = expressions.map(function (expression) {
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
}).join(', ');
return {
clause: ` ORDER BY ${orderBySql}`,
sortKey: normalizedSortKey,
sortDirection: normalizedSortDirection
};
}
function findTopLevelOrderByIndex(sql) {
const text = String(sql || '');
let depth = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
let inBacktick = false;
let lastOrderByIndex = -1;
for (let index = 0; index < text.length; index += 1) {
const character = text[index];
const previousCharacter = index > 0 ? text[index - 1] : '';
if (inSingleQuote) {
if (character === '\'' && previousCharacter !== '\\') {
inSingleQuote = false;
}
continue;
}
if (inDoubleQuote) {
if (character === '"' && previousCharacter !== '\\') {
inDoubleQuote = false;
}
continue;
}
if (inBacktick) {
if (character === '`') {
inBacktick = false;
}
continue;
}
if (character === '\'') {
inSingleQuote = true;
continue;
}
if (character === '"') {
inDoubleQuote = true;
continue;
}
if (character === '`') {
inBacktick = true;
continue;
}
if (character === '(') {
depth += 1;
continue;
}
if (character === ')' && depth > 0) {
depth -= 1;
continue;
}
if (depth === 0 && /[oO]/.test(character)) {
const remaining = text.slice(index);
if (/^order\s+by\b/i.test(remaining)) {
lastOrderByIndex = index;
}
}
}
return lastOrderByIndex;
}
function buildSearchFilter(searchColumns, searchTerm) {
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
return String(column || '').trim();
}).filter(Boolean) : [];
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
if (!columns.length || !normalizedSearchTerm) {
return {
clause: '',
params: []
};
}
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
return {
clause: ` WHERE (${columns.map(function (column) {
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
}).join(' OR ')})`,
params: columns.map(function () {
return likeValue;
})
};
}
async function fetchPagedRows(pool, options) { async function fetchPagedRows(pool, options) {
const selectSql = String(options && options.selectSql || '').trim(); const selectSql = String(options && options.selectSql || '').trim();
const countSql = String(options && options.countSql || '').trim(); const countSql = String(options && options.countSql || '').trim();
const params = Array.isArray(options && options.params) ? options.params : []; const params = Array.isArray(options && options.params) ? options.params : [];
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
const pageSize = Math.max(1, Number(options && options.pageSize) || 10); const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
const currentPage = normalizePageNumber(options && options.page); const currentPage = normalizePageNumber(options && options.page);
if (!selectSql || !countSql) { if (!selectSql || !countSql) {
throw new Error('fetchPagedRows requires selectSql and countSql.'); throw new Error('fetchPagedRows requires selectSql and countSql.');
} }
const orderByIndex = findTopLevelOrderByIndex(selectSql);
let baseSelectSql = selectSql;
let orderBySql = '';
const [countRows] = await pool.query(countSql, params); if (orderByIndex >= 0) {
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
orderBySql = selectSql.slice(orderByIndex).trim();
}
const filteredSelectSql = `${baseSelectSql}${searchFilter.clause}`;
const countQuery = searchFilter.clause
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
: countSql;
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
const [countRows] = await pool.query(countQuery, countParams);
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0; const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
const safeCurrentPage = Math.min(currentPage, totalPages); const safeCurrentPage = Math.min(currentPage, totalPages);
const offset = (safeCurrentPage - 1) * pageSize; const offset = (safeCurrentPage - 1) * pageSize;
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : '');
const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`;
const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset]));
return { return {
rows: rows || [], rows: rows || [],
@@ -77,6 +222,10 @@ module.exports = {
parseJsonSafe, parseJsonSafe,
readFormArray, readFormArray,
normalizePageNumber, normalizePageNumber,
normalizeSortDirection,
buildSortOrderClause,
findTopLevelOrderByIndex,
buildSearchFilter,
fetchPagedRows, fetchPagedRows,
fetchDuplicateName fetchDuplicateName
}; };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

+6 -1
View File
@@ -320,7 +320,12 @@ function handleCommandMessage(rawMessage) {
} }
return; return;
case 'pause': case 'pause':
setPaused(!isPaused); var desiredPause = normalizeBoolean(payload.paused);
if (desiredPause !== null) {
setPaused(desiredPause);
} else {
setPaused(!isPaused);
}
return; return;
case 'blackout': case 'blackout':
var desiredBlackout = normalizeBoolean(payload.blackout); var desiredBlackout = normalizeBoolean(payload.blackout);
+19 -5
View File
@@ -33,6 +33,18 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
}); });
} }
function compareScreenNames(left, right) {
const leftName = String(left && left.name || '').trim();
const rightName = String(right && right.name || '').trim();
const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function createDashboardStateService(options) { function createDashboardStateService(options) {
const pool = options && options.pool; const pool = options && options.pool;
const common = options && options.common; const common = options && options.common;
@@ -82,11 +94,13 @@ function createDashboardStateService(options) {
} }
}); });
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) { const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
return Object.assign({}, screen, { .map(function (screen) {
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` return Object.assign({}, screen, {
}); player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
}); });
})
.sort(compareScreenNames);
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate); const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
return socket && socket.readyState === WebSocket.OPEN; return socket && socket.readyState === WebSocket.OPEN;
+89
View File
@@ -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
};
+52 -11
View File
@@ -17,26 +17,61 @@ function buildQueryString(query) {
return queryString ? `?${queryString}` : ''; 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) { function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
const pages = []; const pages = [];
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages)); const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2)); const firstPage = 1;
let endPage = startPage + visiblePageCount - 1; const lastPage = totalPages;
if (endPage > totalPages) { if (totalPages <= visiblePageCount) {
endPage = totalPages; for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) {
startPage = Math.max(1, endPage - visiblePageCount + 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) { for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber }); pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
pages.push({
number: pageNumber,
active: pageNumber === currentPage,
url: buildQueryString(nextQuery)
});
} }
if (endPage < lastPage - 1) {
pages.push(createEllipsisEntry());
}
pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState));
return pages; return pages;
} }
@@ -51,6 +86,8 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 }); const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 }); const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 });
const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages });
return { return {
currentPage: safeCurrentPage, currentPage: safeCurrentPage,
@@ -59,8 +96,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
hasMultiplePages: totalPages > 1, hasMultiplePages: totalPages > 1,
startItem: startIndex + 1, startItem: startIndex + 1,
endItem: endIndex, endItem: endIndex,
hasFirst: safeCurrentPage > 1,
hasPrevious: safeCurrentPage > 1, hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages, hasNext: safeCurrentPage < totalPages,
hasLast: safeCurrentPage < totalPages,
firstUrl: buildQueryString(firstQuery),
lastUrl: buildQueryString(lastQuery),
previousUrl: buildQueryString(previousQuery), previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery), nextUrl: buildQueryString(nextQuery),
pages: pages, pages: pages,
+25 -2
View File
@@ -28,7 +28,7 @@ async function fetchRoles(pool) {
return rows || []; return rows || [];
} }
async function fetchRolesPage(pool, page, pageSize) { async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
(SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, (SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count,
@@ -36,6 +36,18 @@ async function fetchRolesPage(pool, page, pageSize) {
FROM roles r FROM roles r
ORDER BY r.name ASC`, ORDER BY r.name ASC`,
countSql: 'SELECT COUNT(*) AS count FROM roles', countSql: 'SELECT COUNT(*) AS count FROM roles',
searchColumns: ['r.role_key', 'r.name', 'r.description'],
searchTerm: searchTerm,
sortColumns: {
role: 'r.name',
description: 'r.description',
users: 'user_count',
permissions: 'permission_count',
created: 'r.created_at',
modified: 'r.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
@@ -122,7 +134,7 @@ async function fetchUsersWithRoles(pool) {
}); });
} }
async function fetchUsersWithRolesPage(pool, page, pageSize) { async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
const paged = await fetchPagedRows(pool, { const paged = await fetchPagedRows(pool, {
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
COALESCE(role_data.role_names, '') AS role_names, COALESCE(role_data.role_names, '') AS role_names,
@@ -138,6 +150,17 @@ async function fetchUsersWithRolesPage(pool, page, pageSize) {
) role_data ON role_data.user_id = u.id ) role_data ON role_data.user_id = u.id
ORDER BY u.id ASC`, ORDER BY u.id ASC`,
countSql: 'SELECT COUNT(*) AS count FROM users', countSql: 'SELECT COUNT(*) AS count FROM users',
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
searchTerm: searchTerm,
sortColumns: {
user: 'u.username',
name: 'u.name',
roles: 'role_names',
created: 'u.created_at',
modified: 'u.modified_at'
},
sortKey: sortKey,
sortDirection: sortDirection,
page: page, page: page,
pageSize: pageSize pageSize: pageSize
}); });
+129 -31
View File
@@ -267,7 +267,7 @@
} }
.dashboard-actions-card .card-header, .dashboard-actions-card .card-header,
.dashboard-table-card .card-header { .dashboard-screen-card-shell .card-header {
padding-top: 1rem; padding-top: 1rem;
padding-bottom: 1rem; padding-bottom: 1rem;
} }
@@ -309,6 +309,107 @@
font-size: 0.875rem; font-size: 0.875rem;
} }
.dashboard-screen-card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
}
.dashboard-screen-card-header .dashboard-card-heading {
flex: 1 1 auto;
min-width: 0;
}
.dashboard-screen-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
gap: 1rem;
}
.dashboard-screen-tile {
display: grid;
gap: 0.95rem;
padding: 1rem;
border: 1px solid var(--bs-border-color);
border-radius: calc(var(--bs-border-radius) + 0.25rem);
background: var(--bs-body-bg);
}
.dashboard-screen-tile-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.75rem;
}
.dashboard-screen-tile-text {
min-width: 0;
}
.dashboard-screen-name {
margin: 0;
font-size: 1rem;
line-height: 1.2;
}
.dashboard-screen-link {
display: inline-block;
margin-top: 0.25rem;
color: var(--bs-secondary-color);
font-size: 0.875rem;
overflow-wrap: anywhere;
}
.dashboard-screen-pill {
display: inline-flex;
align-items: center;
flex: 0 0 auto;
padding: 0.3rem 0.6rem;
border-radius: 999px;
font-size: 0.75rem;
font-weight: 700;
white-space: nowrap;
}
.dashboard-screen-pill.is-live {
background: rgba(25, 135, 84, 0.12);
color: var(--bs-success);
}
.dashboard-screen-pill.is-idle {
background: rgba(108, 117, 125, 0.12);
color: var(--bs-secondary-color);
}
.dashboard-screen-meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0.75rem;
margin: 0;
}
.dashboard-screen-meta div {
min-width: 0;
}
.dashboard-screen-meta dt {
color: var(--bs-secondary-color);
font-size: 0.75rem;
font-weight: 700;
letter-spacing: 0.04em;
text-transform: uppercase;
}
.dashboard-screen-meta dd {
margin: 0.15rem 0 0;
overflow-wrap: anywhere;
}
.dashboard-screen-empty {
margin: 0;
padding: 0.25rem 0;
}
.page-header { .page-header {
display: flex; display: flex;
align-items: flex-end; align-items: flex-end;
@@ -1050,7 +1151,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
position: relative; position: relative;
width: 100%; width: 100%;
min-height: 18rem; min-height: 18rem;
overflow: hidden; /* overflow: hidden; */
border: 1px solid var(--bs-border-color); border: 1px solid var(--bs-border-color);
border-radius: 0; border-radius: 0;
background: var(--bs-body-bg); background: var(--bs-body-bg);
@@ -1079,7 +1180,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.designer-overlay { .designer-overlay {
z-index: 2; z-index: 2;
cursor: crosshair; cursor: default;
} }
.designer-rect { .designer-rect {
@@ -1087,9 +1188,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
border: 2px solid rgba(13, 110, 253, 0.95); border: 2px solid rgba(13, 110, 253, 0.95);
background: rgba(13, 110, 253, 0.12); background: rgba(13, 110, 253, 0.12);
box-sizing: border-box; box-sizing: border-box;
border-radius: 0.35rem; border-radius: 0;
min-width: 12px; min-width: 12px;
min-height: 12px; min-height: 12px;
cursor: move;
} }
.designer-rect.selected { .designer-rect.selected {
@@ -1099,12 +1201,12 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.designer-rect-label { .designer-rect-label {
position: absolute; position: absolute;
left: 0; left: 0.4rem;
top: -1.55rem; top: 0.35rem;
max-width: 100%; max-width: 100%;
padding: 0.15rem 0.45rem; padding: 0.15rem 0.45rem;
border-radius: 999px; border-radius: 999px;
background: rgba(33, 37, 41, 0.88); background: rgba(33, 37, 41, 0.92);
color: #fff; color: #fff;
font-size: 0.75rem; font-size: 0.75rem;
line-height: 1.2; line-height: 1.2;
@@ -1128,26 +1230,30 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
} }
.resize-handle.nw { .resize-handle.nw {
left: -0.4rem; left: 0;
top: -0.4rem; top: 0;
transform: translate(-50%, -50%);
cursor: nwse-resize; cursor: nwse-resize;
} }
.resize-handle.ne { .resize-handle.ne {
right: -0.4rem; right: 0;
top: -0.4rem; top: 0;
transform: translate(50%, -50%);
cursor: nesw-resize; cursor: nesw-resize;
} }
.resize-handle.sw { .resize-handle.sw {
left: -0.4rem; left: 0;
bottom: -0.4rem; bottom: 0;
transform: translate(-50%, 50%);
cursor: nesw-resize; cursor: nesw-resize;
} }
.resize-handle.se { .resize-handle.se {
right: -0.4rem; right: 0;
bottom: -0.4rem; bottom: 0;
transform: translate(50%, 50%);
cursor: nwse-resize; cursor: nwse-resize;
} }
@@ -1376,8 +1482,14 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
margin: 0; margin: 0;
} }
.region-item .remove-region { .region-item [data-region-remove-button] {
justify-self: end; width: 100%;
}
.region-item [data-region-remove-button]:disabled {
opacity: 0.65;
box-shadow: none;
cursor: not-allowed;
} }
.region-item .form-control:focus, .region-item .form-control:focus,
@@ -1585,10 +1697,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
white-space: nowrap; white-space: nowrap;
} }
.playlist-slide-picker-meta {
display: none;
}
.playlist-slide-picker-check { .playlist-slide-picker-check {
position: absolute; position: absolute;
top: 0.5rem; top: 0.5rem;
@@ -1856,16 +1964,6 @@ table.table thead th.sort-desc .table-sort-indicator {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.dashboard-hero-body {
flex-direction: column;
align-items: stretch;
}
.dashboard-hero-stats {
grid-template-columns: 1fr;
min-width: 0;
}
.page-header { .page-header {
align-items: flex-start; align-items: flex-start;
flex-direction: column; flex-direction: column;
Binary file not shown.

Before

Width:  |  Height:  |  Size: 332 KiB

After

Width:  |  Height:  |  Size: 19 KiB

-599
View File
@@ -1,599 +0,0 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function initConfirmForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.getAttribute) {
return;
}
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
}
});
}
function markFormDirty(form) {
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
return;
}
form.dataset.dirty = 'true';
}
function clearFormDirty(form) {
if (!form) {
return;
}
form.dataset.dirty = 'false';
}
function isFormDirty(form) {
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
}
function initDirtyTracking() {
document.addEventListener('input', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
document.addEventListener('change', function (event) {
var target = event.target;
if (!target || !target.form) {
return;
}
markFormDirty(target.form);
}, true);
}
function initCancelConfirm() {
document.addEventListener('click', function (event) {
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
if (!cancelTarget) {
return;
}
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
if (!isFormDirty(form)) {
return;
}
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
if (!window.confirm(message)) {
event.preventDefault();
event.stopPropagation();
}
}, true);
}
function initAsyncCommandForms() {
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var formData = new FormData(form);
var body = new URLSearchParams();
formData.forEach(function (value, key) {
body.append(key, value);
});
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
body: body.toString(),
credentials: 'same-origin'
}).finally(function () {
delete form.dataset.busy;
});
}, true);
}
function initAsyncSaveForms() {
var refreshSequence = 0;
function setSaveActionValue(form, value) {
if (!form) {
return;
}
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
if (!hiddenInput) {
hiddenInput = document.createElement('input');
hiddenInput.type = 'hidden';
hiddenInput.name = 'save_action';
form.appendChild(hiddenInput);
}
hiddenInput.value = String(value || '').trim().toLowerCase();
}
function getResponseQueryValue(responseUrl, key) {
try {
var url = new URL(responseUrl, window.location.href);
return String(url.searchParams.get(key) || '').trim();
} catch (_error) {
return '';
}
}
function rebindRefreshTarget(targetElement) {
if (!targetElement) {
return;
}
if (typeof window.initJsonTogglePanels === 'function') {
window.initJsonTogglePanels(targetElement);
}
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(targetElement);
}
}
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
if (sequenceId !== refreshSequence) {
return;
}
fetch(window.location.href, {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/xhtml+xml'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to refresh saved data.');
}
return response.text();
}).then(function (text) {
if (sequenceId !== refreshSequence) {
return;
}
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (!currentTarget || !nextTarget) {
return;
}
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) {
// Ignore refresh replacement failures and leave the existing content in place.
});
}
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
var pollDelayMs = 1000;
var maxAttempts = 60;
function poll(attempt) {
if (sequenceId !== refreshSequence) {
return;
}
var stateUrl;
try {
stateUrl = new URL(refreshStateUrl, window.location.href);
} catch (_error) {
return;
}
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
fetch(stateUrl.toString(), {
method: 'GET',
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'application/json, text/plain, */*'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to check refresh status.');
}
return response.json();
}).then(function (payload) {
if (sequenceId !== refreshSequence) {
return;
}
var status = String(payload && payload.status || '').trim().toLowerCase();
if (status === 'queued' || status === 'running') {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
return;
}
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
}).catch(function () {
if (attempt < maxAttempts) {
window.setTimeout(function () {
poll(attempt + 1);
}, pollDelayMs);
}
});
}
poll(0);
}
document.addEventListener('click', function (event) {
var target = event.target;
if (!target || !target.closest) {
return;
}
var button = target.closest('button[name="save_action"]');
if (!button) {
return;
}
var form = button.form || button.closest('form');
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
setSaveActionValue(form, button.value || '');
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
}, true);
document.addEventListener('submit', function (event) {
var form = event.target;
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
return;
}
if (form.dataset && form.dataset.busy === 'true') {
event.preventDefault();
return;
}
var message = form.getAttribute('data-confirm-message');
if (message && !window.confirm(message)) {
event.preventDefault();
return;
}
event.preventDefault();
form.dataset.busy = 'true';
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
var formData = new FormData(form);
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
if (event.submitter && event.submitter.name) {
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
formData.set(event.submitter.name, event.submitter.value || '');
}
var hasFileValue = false;
formData.forEach(function (value) {
if (value && typeof value === 'object' && typeof value.name === 'string') {
hasFileValue = true;
}
});
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
var body = isMultipart ? formData : new URLSearchParams();
if (!isMultipart) {
formData.forEach(function (value, key) {
body.append(key, value);
});
}
fetch(form.action, {
method: (form.method || 'POST').toUpperCase(),
headers: Object.assign({
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
}, isMultipart ? {} : {
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
}),
body: isMultipart ? body : body.toString(),
credentials: 'same-origin'
}).then(function (response) {
if (!response.ok) {
return response.text().then(function (text) {
var error = new Error(text || 'Unable to save changes.');
error.status = response.status;
throw error;
});
}
var actionUrl = '';
try {
actionUrl = new URL(form.action, window.location.href).pathname;
} catch (_error) {
actionUrl = String(form.action || '');
}
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
window.location.replace(redirectUrl);
return;
}
if (shouldFollowRedirect && response.url) {
clearFormDirty(form);
window.location.replace(response.url);
return;
}
if (form.hasAttribute('data-async-save-reload-on-success')) {
clearFormDirty(form);
window.location.replace(response.url || window.location.href);
return;
}
clearFormDirty(form);
return response.text().then(function (text) {
var savedMessage = '';
var responseDocument = null;
try {
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
var toastBody = responseDocument.querySelector('.toast-body');
if (toastBody && toastBody.textContent) {
savedMessage = toastBody.textContent.trim();
}
} catch (_error) {
savedMessage = '';
}
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
refreshSequence += 1;
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
} else if (refreshTargetSelector && responseDocument) {
var currentTarget = document.querySelector(refreshTargetSelector);
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
if (currentTarget && nextTarget) {
currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}
}
showToast(savedMessage || 'Saved.', 'success');
});
}).catch(function (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
showToast(error.message || 'Unable to save changes.', variant);
return;
}
window.alert(error.message || 'Unable to save changes.');
}).finally(function () {
delete form.dataset.busy;
delete form.dataset.submitterValue;
if (hiddenSaveAction) {
hiddenSaveAction.value = '';
}
});
}, true);
}
function initSubmitOnChange() {
var fields = document.querySelectorAll('[data-submit-on-change]');
Array.prototype.forEach.call(fields, function (field) {
field.addEventListener('change', function () {
var form = field.form || field.closest('form');
if (form) {
form.submit();
}
});
});
}
function initJsonTogglePanels(root) {
var scope = root && root.querySelectorAll ? root : document;
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
Array.prototype.forEach.call(panels, function (panel) {
var output = panel.querySelector('[data-json-toggle-output]');
if (!output) {
return;
}
var card = panel.closest ? panel.closest('.card') : null;
var button = card ? card.querySelector('[data-json-toggle]') : null;
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
var sourceNode = panel.querySelector('[data-json-toggle-source]');
var rawJson = '';
try {
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
} catch (_error) {
rawJson = '';
}
if (typeof rawJson !== 'string' || !rawJson.trim()) {
if (button) {
button.classList.add('d-none');
}
return;
}
var parsedJson;
try {
parsedJson = JSON.parse(rawJson);
} catch (_error) {
if (button) {
button.classList.add('d-none');
}
return;
}
var compactJson = JSON.stringify(parsedJson);
var formattedJson = JSON.stringify(parsedJson, null, 2);
var isFormatted = true;
function syncButtonLabel() {
if (!button || !label) {
return;
}
label.textContent = isFormatted
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
}
function syncOutput() {
output.textContent = isFormatted ? formattedJson : compactJson;
syncButtonLabel();
}
output.textContent = formattedJson;
syncButtonLabel();
if (button) {
button.addEventListener('click', function () {
isFormatted = !isFormatted;
syncOutput();
});
}
});
}
function initLocalDateTimes(root) {
var scope = root && root.querySelectorAll ? root : document;
var elements = scope.querySelectorAll('[data-local-datetime]');
Array.prototype.forEach.call(elements, function (element) {
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
if (!rawValue) {
return;
}
var date = new Date(rawValue);
if (Number.isNaN(date.getTime())) {
return;
}
element.textContent = formatDashboardDate(date);
});
}
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms();
initDirtyTracking();
initCancelConfirm();
initAsyncCommandForms();
initAsyncSaveForms();
initSubmitOnChange();
initJsonTogglePanels();
initLocalDateTimes();
window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes;
}());
+11 -394
View File
@@ -1,84 +1,11 @@
(function () { (function () {
var webUiHelpers = window.webUiHelpers || {};
function escapeHtml(value) { var escapeHtml = webUiHelpers.escapeHtml;
return String(value ?? '') var formatDashboardDate = webUiHelpers.formatDashboardDate;
.replace(/&/g, '&amp;') var getClientRowKey = webUiHelpers.getClientRowKey;
.replace(/</g, '&lt;') var getClientDisplayName = webUiHelpers.getClientDisplayName;
.replace(/>/g, '&gt;') var setButtonVariant = webUiHelpers.setButtonVariant;
.replace(/"/g, '&quot;') var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function initConfirmForms() { function initConfirmForms() {
document.addEventListener('submit', function (event) { document.addEventListener('submit', function (event) {
@@ -237,9 +164,6 @@
if (typeof window.initTableSearches === 'function') { if (typeof window.initTableSearches === 'function') {
window.initTableSearches(targetElement); window.initTableSearches(targetElement);
} }
if (typeof window.initTablePaginations === 'function') {
window.initTablePaginations(targetElement);
}
} }
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) { function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
@@ -274,7 +198,7 @@
currentTarget.outerHTML = nextTarget.outerHTML; currentTarget.outerHTML = nextTarget.outerHTML;
rebindRefreshTarget(document.querySelector(refreshTargetSelector)); rebindRefreshTarget(document.querySelector(refreshTargetSelector));
}).catch(function (_error) { }).catch(function () {
// Ignore refresh replacement failures and leave the existing content in place. // Ignore refresh replacement failures and leave the existing content in place.
}); });
} }
@@ -588,303 +512,10 @@
}); });
} }
function initTablePaginations(root) {
var scope = root && root.querySelectorAll ? root : document;
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
if (container.getAttribute('data-table-pagination-bound') === 'true') {
return;
}
var table = container.querySelector('[data-table-searchable]');
var cardHeader = container.querySelector('.card-header');
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
var footer = container.querySelector('[data-table-pagination-controls]');
var footerSummary = null;
var footerList = null;
var syncFrame = 0;
var state = {
currentPage: 1,
pageSize: 1,
totalPages: 1
};
if (!table || !cardBody || !table.tBodies.length) {
return;
}
container.setAttribute('data-table-pagination-bound', 'true');
function getRows() {
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
}
function isSearchMatched(row) {
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
}
function ensureFooter() {
if (footer) {
footerSummary = footer.querySelector('[data-table-pagination-summary]');
footerList = footer.querySelector('[data-table-pagination-list]');
return footer;
}
footer = document.createElement('div');
footer.className = 'card-footer d-none';
footer.setAttribute('data-table-pagination-controls', 'true');
footer.innerHTML = '' +
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
'<div class="text-muted small" data-table-pagination-summary></div>' +
'<nav aria-label="Table pages">' +
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
'</nav>' +
'</div>';
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
footerSummary = footer.querySelector('[data-table-pagination-summary]');
footerList = footer.querySelector('[data-table-pagination-list]');
return footer;
}
function setRowVisible(row, visible) {
row.hidden = !visible;
row.classList.toggle('d-none', !visible);
}
function getAvailableHeight() {
var containerRect = container.getBoundingClientRect();
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
}
function renderFooter(totalItems) {
var pageButtons = [];
ensureFooter();
if (state.totalPages <= 1 || totalItems <= 0) {
footer.classList.add('d-none');
footerSummary.textContent = '';
footerList.innerHTML = '';
return;
}
footer.classList.remove('d-none');
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
}
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
footerList.innerHTML = pageButtons.join('');
}
function applyPagination(resetPage) {
var rows = getRows();
var matchedRows = rows.filter(isSearchMatched);
var totalItems = matchedRows.length;
var rowHeight = 48;
var pageSize;
var startIndex;
var endIndex;
if (resetPage) {
state.currentPage = 1;
}
rows.forEach(function (row) {
if (!isSearchMatched(row)) {
setRowVisible(row, false);
}
});
if (!totalItems) {
state.pageSize = 1;
state.totalPages = 1;
state.currentPage = 1;
renderFooter(0);
return;
}
matchedRows.forEach(function (row) {
setRowVisible(row, true);
});
if (matchedRows[0]) {
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
}
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
if (pageSize >= totalItems) {
state.pageSize = totalItems;
state.totalPages = 1;
state.currentPage = 1;
matchedRows.forEach(function (row) {
setRowVisible(row, true);
});
renderFooter(totalItems);
return;
}
state.pageSize = pageSize;
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
startIndex = (state.currentPage - 1) * pageSize;
endIndex = startIndex + pageSize;
matchedRows.forEach(function (row, index) {
setRowVisible(row, index >= startIndex && index < endIndex);
});
renderFooter(totalItems);
}
function scheduleSync(options) {
if (syncFrame) {
window.cancelAnimationFrame(syncFrame);
}
syncFrame = window.requestAnimationFrame(function () {
syncFrame = 0;
applyPagination(Boolean(options && options.resetPage));
});
}
ensureFooter();
if (footer) {
footer.addEventListener('click', function (event) {
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
var pageValue;
if (!target || target.closest('.disabled')) {
return;
}
event.preventDefault();
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
state.currentPage = pageValue;
scheduleSync();
});
}
window.addEventListener('resize', function () {
scheduleSync();
});
if (typeof ResizeObserver === 'function') {
try {
new ResizeObserver(function () {
scheduleSync();
}).observe(container);
} catch (_error) {
// Ignore observer setup failures and rely on window resize.
}
}
container.syncTablePagination = scheduleSync;
scheduleSync({ resetPage: true });
});
}
function initTableSearches(root) { function initTableSearches(root) {
var scope = root && root.querySelectorAll ? root : document; if (typeof window.initTableSearches === 'function') {
window.initTableSearches(root);
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) { }
if (input.getAttribute('data-table-search-bound') === 'true') {
return;
}
var container = input.closest('[data-table-search-container]') || input.closest('.card') || document;
var table = container.querySelector('[data-table-searchable]');
var emptyRow = null;
if (!table) {
return;
}
input.setAttribute('data-table-search-bound', 'true');
function getSearchableRows() {
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
}
function getDefaultEmptyRow() {
return table.querySelector('tbody tr[data-table-search-empty-default]');
}
function removeGeneratedEmptyRow() {
if (emptyRow && emptyRow.parentNode) {
emptyRow.parentNode.removeChild(emptyRow);
}
emptyRow = null;
}
function ensureGeneratedEmptyRow(message) {
var tbody = table.tBodies[0] || table.querySelector('tbody');
var columnCount = 1;
if (!tbody) {
return;
}
if (!emptyRow) {
emptyRow = document.createElement('tr');
emptyRow.setAttribute('data-table-search-empty-row', 'true');
var cell = document.createElement('td');
cell.className = 'empty';
cell.setAttribute('data-table-search-empty-cell', 'true');
emptyRow.appendChild(cell);
}
columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1);
emptyRow.firstElementChild.colSpan = Math.max(1, columnCount);
emptyRow.firstElementChild.textContent = message;
if (!emptyRow.parentNode) {
tbody.appendChild(emptyRow);
}
}
function syncSearch() {
var query = String(input.value || '').trim().toLowerCase();
var rows = getSearchableRows();
var visibleCount = 0;
var defaultEmptyRow = getDefaultEmptyRow();
removeGeneratedEmptyRow();
rows.forEach(function (row) {
var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase();
var visible = !query || haystack.indexOf(query) !== -1;
row.setAttribute('data-table-search-match', visible ? 'true' : 'false');
row.classList.toggle('d-none', !visible);
row.hidden = !visible;
if (visible) {
visibleCount += 1;
}
});
if (defaultEmptyRow) {
defaultEmptyRow.classList.toggle('d-none', Boolean(query));
defaultEmptyRow.hidden = Boolean(query);
}
if (query && visibleCount === 0) {
ensureGeneratedEmptyRow('No results match your search.');
}
if (typeof window.syncTablePagination === 'function') {
window.syncTablePagination(container, { resetPage: true });
}
}
input.addEventListener('input', syncSearch);
syncSearch();
});
} }
function createSlideThumbPlaceholder() { function createSlideThumbPlaceholder() {
@@ -914,10 +545,6 @@
}); });
} }
if (window.initSortableTables) {
window.initSortableTables();
}
initConfirmForms(); initConfirmForms();
initDirtyTracking(); initDirtyTracking();
initCancelConfirm(); initCancelConfirm();
@@ -927,19 +554,9 @@
initJsonTogglePanels(); initJsonTogglePanels();
initLocalDateTimes(); initLocalDateTimes();
initTableSearches(); initTableSearches();
initTablePaginations();
attachSlideThumbFallbacks(document); attachSlideThumbFallbacks(document);
window.initJsonTogglePanels = initJsonTogglePanels; window.initJsonTogglePanels = initJsonTogglePanels;
window.initLocalDateTimes = initLocalDateTimes; window.initLocalDateTimes = initLocalDateTimes;
window.initTableSearches = initTableSearches;
window.initTablePaginations = initTablePaginations;
window.syncTablePagination = function (container, options) {
if (!container || !container.syncTablePagination) {
return;
}
container.syncTablePagination(options);
};
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder; window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks; window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
}()); }());
-111
View File
@@ -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();
}());
-102
View File
@@ -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();
}());
-171
View File
@@ -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();
}());
-134
View File
@@ -1,134 +0,0 @@
(function () {
function getBootstrapToast(toast) {
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
return null;
}
return window.bootstrap.Toast.getOrCreateInstance(toast, {
autohide: true,
delay: 4000
});
}
function removeToast(toast) {
if (toast && toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}
function dismissToast(toast) {
var instance = getBootstrapToast(toast);
if (instance) {
instance.hide();
return;
}
removeToast(toast);
}
function setToastVariant(toast, variant) {
if (!toast || !toast.classList) {
return;
}
var nextVariant = String(variant || 'info').trim().toLowerCase();
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
variants.forEach(function (value) {
toast.classList.remove('text-bg-' + value);
});
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
}
function getMessageVariant(message, fallbackVariant) {
var text = String(message || '').trim();
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
return 'danger';
}
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
return 'warning';
}
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
}
function showToast(message, variant) {
var text = String(message || '').trim();
if (!text) {
return;
}
var container = document.getElementById('app-toast-container');
if (!container) {
return;
}
var existingToast = document.getElementById('app-toast');
if (existingToast) {
var existingBody = existingToast.querySelector('.toast-body');
if (existingBody) {
existingBody.textContent = text;
}
var nextVariant = getMessageVariant(text, variant);
existingToast.setAttribute('data-toast-variant', nextVariant);
setToastVariant(existingToast, nextVariant);
var existingInstance = getBootstrapToast(existingToast);
if (existingInstance) {
existingInstance.show();
}
return;
}
var toast = document.createElement('div');
toast.className = 'toast align-items-center border-0';
toast.id = 'app-toast';
toast.setAttribute('role', 'status');
toast.setAttribute('aria-live', 'polite');
toast.setAttribute('aria-atomic', 'true');
toast.setAttribute('data-bs-autohide', 'true');
toast.setAttribute('data-bs-delay', '4000');
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
var toastVariant = getMessageVariant(text, variant);
toast.setAttribute('data-toast-variant', toastVariant);
setToastVariant(toast, toastVariant);
toast.querySelector('.toast-body').textContent = text;
toast.addEventListener('hidden.bs.toast', function () {
removeToast(toast);
});
container.appendChild(toast);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
function initToast() {
var toast = document.getElementById('app-toast');
if (!toast) {
return;
}
try {
var url = new URL(window.location.href);
if (url.searchParams.has('message')) {
url.searchParams.delete('message');
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
}
} catch (_error) {
// ignore URL cleanup failures
}
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
setToastVariant(toast, existingVariant);
var instance = getBootstrapToast(toast);
if (instance) {
instance.show();
}
}
window.dismissToast = dismissToast;
window.showToast = showToast;
window.initToast = initToast;
initToast();
}());
+76 -100
View File
@@ -1,83 +1,11 @@
(function () { (function () {
function escapeHtml(value) { var webUiHelpers = window.webUiHelpers || {};
return String(value ?? '') var escapeHtml = webUiHelpers.escapeHtml;
.replace(/&/g, '&amp;') var formatDashboardDate = webUiHelpers.formatDashboardDate;
.replace(/</g, '&lt;') var getClientRowKey = webUiHelpers.getClientRowKey;
.replace(/>/g, '&gt;') var getClientDisplayName = webUiHelpers.getClientDisplayName;
.replace(/"/g, '&quot;') var setButtonVariant = webUiHelpers.setButtonVariant;
.replace(/'/g, '&#39;'); var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
function renderClientActionCell(client) { function renderClientActionCell(client) {
var paused = Boolean(client.paused); var paused = Boolean(client.paused);
@@ -252,18 +180,50 @@
].join(''); ].join('');
} }
function renderScreenRow(screen) { function renderScreenTile(screen) {
var clientCount = Number(screen.player_connection_count || 0); var clientCount = Number(screen.player_connection_count || 0);
var playerUrl = String(screen.player_url || '').trim();
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
return [ return [
'<tr>', '<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>', '<div class="dashboard-screen-tile-top">',
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>', '<div class="dashboard-screen-tile-text">',
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>', '<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>', '<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
'</tr>' '</div>',
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
'</div>',
'<dl class="dashboard-screen-meta">',
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
'</dl>',
'</article>'
].join(''); ].join('');
} }
function compareScreensByConnectedClients(left, right) {
var leftCount = Number(left && left.player_connection_count || 0);
var rightCount = Number(right && right.player_connection_count || 0);
if (leftCount !== rightCount) {
return rightCount - leftCount;
}
var leftName = String(left && left.name || '').trim();
var rightName = String(right && right.name || '').trim();
var nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
if (nameCompare !== 0) {
return nameCompare;
}
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
}
function updateStats(state) { function updateStats(state) {
var clientCount = document.getElementById('dashboard-client-count'); var clientCount = document.getElementById('dashboard-client-count');
var screenCount = document.getElementById('dashboard-screen-count'); var screenCount = document.getElementById('dashboard-screen-count');
@@ -352,36 +312,52 @@
tbody.removeChild(tbody.lastElementChild); tbody.removeChild(tbody.lastElementChild);
} }
window.applyTableSort(document.getElementById('dashboard-clients-table'));
} }
function updateScreenTable(state) { function updateScreenGrid(state) {
var table = document.getElementById('dashboard-screens-table'); var grid = document.getElementById('dashboard-screens-grid');
if (!table || !Array.isArray(state.screens)) { if (!grid || !Array.isArray(state.screens)) {
return; return;
} }
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null; var screens = state.screens.slice().sort(compareScreensByConnectedClients);
if (!tbody) {
if (!screens.length) {
grid.innerHTML = '<div class="empty dashboard-screen-empty">No screens yet.</div>';
return; return;
} }
if (!state.screens.length) { grid.innerHTML = screens.map(renderScreenTile).join('');
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
return;
}
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
window.applyTableSort(table);
} }
function updateDashboardQuickActions(state) { function updateDashboardQuickActions(state) {
var pauseButton = document.getElementById('dashboard-pause-all-button');
var blackoutButton = document.getElementById('dashboard-blackout-all-button'); var blackoutButton = document.getElementById('dashboard-blackout-all-button');
if (!blackoutButton || !state || !Array.isArray(state.clients)) { if ((!pauseButton && !blackoutButton) || !state || !Array.isArray(state.clients)) {
return; return;
} }
var hasClients = state.clients.length > 0; var hasClients = state.clients.length > 0;
var allPaused = hasClients && state.clients.every(function (client) {
return Boolean(client && client.paused);
});
var allBlackout = hasClients && state.clients.every(function (client) { var allBlackout = hasClients && state.clients.every(function (client) {
return Boolean(client && client.blackout); return Boolean(client && client.blackout);
}); });
if (pauseButton) {
var pauseForm = pauseButton.form;
var pauseInput = pauseForm ? pauseForm.querySelector('input[name="paused"]') : null;
var pauseLabel = allPaused ? 'Resume all clients' : 'Pause all clients';
var pauseButtonIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
pauseButton.innerHTML = '<i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
if (pauseInput) {
pauseInput.value = allPaused ? 'false' : 'true';
}
if (pauseForm) {
pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?');
}
pauseButton.setAttribute('aria-label', pauseLabel);
}
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients'; var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
var blackoutForm = blackoutButton.form; var blackoutForm = blackoutButton.form;
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null; var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
@@ -403,7 +379,7 @@
return; return;
} }
updateStats(state); updateStats(state);
updateScreenTable(state); updateScreenGrid(state);
updateClientTable(state); updateClientTable(state);
updateDashboardQuickActions(state); updateDashboardQuickActions(state);
} }
+264 -180
View File
@@ -1,6 +1,26 @@
(function () { (function () {
// Shared constants used across the playlist schedule editor.
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
var VIDEO_DURATION_LABEL = 'Use video duration';
var VIDEO_DURATION_ACTIVE_LABEL = 'Use Video Duration';
var VIDEO_DURATION_LOADING_LABEL = 'Loading...';
var PICKER_THUMB_EMPTY_LABEL = 'No thumbnail';
var PICKER_ASSIGNED_BADGE_LABEL = 'Already in playlist';
var PICKER_CARD_CLASS = 'playlist-slide-picker-card';
var PICKER_MEDIA_CLASS = 'playlist-slide-picker-media';
var PICKER_TITLE_CLASS = 'playlist-slide-picker-title';
var PICKER_CHECK_CLASS = 'playlist-slide-picker-check bi bi-check2-circle';
var PICKER_BADGE_CLASS = 'playlist-slide-picker-badge badge text-bg-primary';
var SCHEDULE_ROW_FIELD_NAMES = {
mode: '[name="schedule_mode[]"]',
startDatetime: '[name="schedule_start_datetime[]"]',
endDatetime: '[name="schedule_end_datetime[]"]',
startTime: '[name="schedule_start_time[]"]',
endTime: '[name="schedule_end_time[]"]',
daysJson: '[name="schedule_days_json[]"]'
};
// Schedule helpers.
function formatDays(daysValue) { function formatDays(daysValue) {
var days = []; var days = [];
if (Array.isArray(daysValue)) { if (Array.isArray(daysValue)) {
@@ -21,6 +41,192 @@
.join(', '); .join(', ');
} }
function getScheduleRowFields(row) {
return {
mode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.mode) : null,
startDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startDatetime) : null,
endDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endDatetime) : null,
startTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startTime) : null,
endTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endTime) : null,
daysJson: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.daysJson) : null
};
}
// Video duration helpers.
function buildVideoDurationButtonMarkup(isPressed, enabled) {
if (!enabled) {
return '';
}
return '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (isPressed ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '">' + (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL) + '</button>';
}
function syncVideoDurationButtonState(button, isPressed, label) {
if (!button) {
return;
}
button.classList.toggle('active', Boolean(isPressed));
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
button.textContent = label || (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL);
}
// Slide picker helpers.
function getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount) {
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
return query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
}
return query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
}
function updateSlidePickerCardState(card, isAssigned, isCanvasMismatch) {
if (!card) {
return;
}
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
card.disabled = isAssigned || isCanvasMismatch;
card.classList.toggle('is-assigned', isAssigned);
card.classList.toggle('is-mismatch', isCanvasMismatch);
card.classList.toggle('is-hidden', false);
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
}
function createElementWithClass(tagName, className, textContent) {
var element = document.createElement(tagName);
if (className) {
element.className = className;
}
if (textContent !== undefined) {
element.textContent = textContent;
}
return element;
}
function buildSlidePickerThumbPlaceholder() {
var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder');
var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon');
var label = createElementWithClass('span', 'playlist-slide-picker-thumb-placeholder-label', PICKER_THUMB_EMPTY_LABEL);
icon.setAttribute('aria-hidden', 'true');
placeholder.appendChild(icon);
placeholder.appendChild(label);
return placeholder;
}
function buildSlidePickerThumbImage(slide) {
var image = createElementWithClass('img', 'playlist-slide-picker-thumb-image');
image.loading = 'lazy';
image.alt = String(slide.title || 'Slide');
image.src = String(slide.thumbnail_path || '');
image.addEventListener('error', function () {
if (image.parentNode) {
image.parentNode.replaceChild(buildSlidePickerThumbPlaceholder(), image);
}
});
return image;
}
function buildSlidePickerMedia(slide) {
var media = createElementWithClass('div', PICKER_MEDIA_CLASS);
var check = createElementWithClass('span', PICKER_CHECK_CLASS);
var badge = createElementWithClass('span', PICKER_BADGE_CLASS, PICKER_ASSIGNED_BADGE_LABEL);
check.setAttribute('aria-hidden', 'true');
badge.setAttribute('aria-hidden', 'true');
media.appendChild(slide && slide.thumbnail_path ? buildSlidePickerThumbImage(slide) : buildSlidePickerThumbPlaceholder());
media.appendChild(check);
media.appendChild(badge);
return media;
}
// Playlist row helpers.
function getPlaylistSlideRow(target) {
return target && target.closest ? target.closest('tr[data-playlist-slide-row]') : null;
}
function getPlaylistSlideRowKey(row) {
return String(row && row.getAttribute('data-row-key') || '');
}
function isDurationControl(target) {
return Boolean(target && target.closest && target.closest('.playlist-use-video-duration'));
}
function getDurationPointerState(target, type) {
var row = getPlaylistSlideRow(target);
var pointerType = type === 'button' ? 'button' : 'input';
if (!row) {
return null;
}
return {
type: pointerType,
rowKey: getPlaylistSlideRowKey(row)
};
}
function buildPlaylistOrderCellMarkup() {
return '' +
'<td class="playlist-order-cell" data-label="Order">' +
'<div class="playlist-order-cell-inner">' +
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
'<span class="playlist-order-number"></span>' +
'</div>' +
'</td>';
}
function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) {
return '' +
'<td data-label="Slide">' +
'<div class="playlist-slide-cell">' +
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
'</div>' +
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
'</td>';
}
function buildPlaylistScheduleCellMarkup(values) {
return '' +
'<td>' +
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
'</td>';
}
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
return '' +
'<td><div class="playlist-duration-field">' +
'<input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
durationActionMarkup +
'</div></td>';
}
function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
return '' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
'</div></td>';
}
// Schedule modal wiring.
function initPlaylistScheduleModal() { function initPlaylistScheduleModal() {
var dialog = document.getElementById('slide-schedule-dialog'); var dialog = document.getElementById('slide-schedule-dialog');
var content = document.getElementById('slide-schedule-content'); var content = document.getElementById('slide-schedule-content');
@@ -38,14 +244,9 @@
function collectScheduleParams(row, rowKey) { function collectScheduleParams(row, rowKey) {
var params = new URLSearchParams(); var params = new URLSearchParams();
var scheduleFields = [ var scheduleFields = Object.keys(SCHEDULE_ROW_FIELD_NAMES).map(function (key) {
'schedule_mode[]', return SCHEDULE_ROW_FIELD_NAMES[key].replace(/^\[name="|"\]$/g, '');
'schedule_start_datetime[]', });
'schedule_end_datetime[]',
'schedule_start_time[]',
'schedule_end_time[]',
'schedule_days_json[]'
];
params.set('row_key', String(rowKey || '')); params.set('row_key', String(rowKey || ''));
scheduleFields.forEach(function (fieldName) { scheduleFields.forEach(function (fieldName) {
@@ -113,6 +314,7 @@
} }
// Schedule form behavior.
function scheduleSummary(values) { function scheduleSummary(values) {
var mode = String(values.schedule_mode || 'always'); var mode = String(values.schedule_mode || 'always');
if (mode === 'dates') { if (mode === 'dates') {
@@ -214,10 +416,6 @@
}); });
select.addEventListener('change', clearScheduleValidity); select.addEventListener('change', clearScheduleValidity);
function notifyParentResize() {
return;
}
function updateVisibility() { function updateVisibility() {
var mode = select.value; var mode = select.value;
if (datesPanel) { if (datesPanel) {
@@ -234,9 +432,6 @@
endTimeInput.value = DEFAULT_END_TIME; endTimeInput.value = DEFAULT_END_TIME;
} }
} }
window.requestAnimationFrame(function () {
notifyParentResize();
});
} }
select.addEventListener('change', updateVisibility); select.addEventListener('change', updateVisibility);
@@ -302,6 +497,7 @@
}); });
} }
// Playlist editor behavior.
function initPlaylistEditStaging() { function initPlaylistEditStaging() {
var tbody = document.getElementById('playlist-items-body'); var tbody = document.getElementById('playlist-items-body');
var form = document.getElementById('playlist-edit-form'); var form = document.getElementById('playlist-edit-form');
@@ -334,13 +530,7 @@
} }
} }
function getModalInstance() { // Selection state helpers for the picker modal.
if (!window.pulseModal) {
return null;
}
return window.pulseModal.getOrCreate(addSlideModal);
}
function setCardSelected(card, selected) { function setCardSelected(card, selected) {
if (!card) { if (!card) {
return; return;
@@ -373,50 +563,13 @@
} }
} }
function createPickerThumb(slide) { // Picker card rendering.
function createPlaceholder() {
var placeholder = document.createElement('div');
var icon = document.createElement('i');
var label = document.createElement('span');
placeholder.className = 'playlist-slide-picker-thumb-placeholder';
icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon';
icon.setAttribute('aria-hidden', 'true');
label.className = 'playlist-slide-picker-thumb-placeholder-label';
label.textContent = 'No thumbnail';
placeholder.appendChild(icon);
placeholder.appendChild(label);
return placeholder;
}
if (slide && slide.thumbnail_path) {
var image = document.createElement('img');
image.className = 'playlist-slide-picker-thumb-image';
image.loading = 'lazy';
image.alt = String(slide.title || 'Slide');
image.src = String(slide.thumbnail_path || '');
image.addEventListener('error', function () {
if (image.parentNode) {
image.parentNode.replaceChild(createPlaceholder(), image);
}
});
return image;
}
return createPlaceholder();
}
function createPickerCard(slide) { function createPickerCard(slide) {
var button = document.createElement('button'); var button = document.createElement('button');
var media = document.createElement('div');
var title = document.createElement('div');
var check = document.createElement('span');
var badge = document.createElement('span');
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase(); var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
button.type = 'button'; button.type = 'button';
button.className = 'playlist-slide-picker-card'; button.className = PICKER_CARD_CLASS;
button.setAttribute('aria-pressed', 'false'); button.setAttribute('aria-pressed', 'false');
button.setAttribute('data-slide-id', String(slide.id || '')); button.setAttribute('data-slide-id', String(slide.id || ''));
button.setAttribute('data-search-text', searchText); button.setAttribute('data-search-text', searchText);
@@ -427,18 +580,9 @@
button.classList.add('is-assigned'); button.classList.add('is-assigned');
} }
media.className = 'playlist-slide-picker-media'; var media = buildSlidePickerMedia(slide);
media.appendChild(createPickerThumb(slide)); var title = document.createElement('div');
check.className = 'playlist-slide-picker-check bi bi-check2-circle'; title.className = PICKER_TITLE_CLASS;
check.setAttribute('aria-hidden', 'true');
media.appendChild(check);
badge.className = 'playlist-slide-picker-badge badge text-bg-primary';
badge.textContent = 'Already in playlist';
badge.setAttribute('aria-hidden', 'true');
media.appendChild(badge);
title.className = 'playlist-slide-picker-title';
title.textContent = String(slide && slide.title ? slide.title : 'Slide'); title.textContent = String(slide && slide.title ? slide.title : 'Slide');
button.appendChild(media); button.appendChild(media);
@@ -447,6 +591,7 @@
return button; return button;
} }
// Picker filtering and modal controls.
function setShowAssignedState(showAssignedSlides) { function setShowAssignedState(showAssignedSlides) {
if (!addSlideShowAssigned) { if (!addSlideShowAssigned) {
return; return;
@@ -492,11 +637,7 @@
}); });
if (addSlideEmpty) { if (addSlideEmpty) {
if (!showAssignedSlides && assignedCount && visibleCount === 0) { addSlideEmpty.textContent = getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount);
addSlideEmpty.textContent = query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
} else {
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
}
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0); addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
} }
@@ -615,24 +756,19 @@
} }
function scheduleSummaryForRow(row) { function scheduleSummaryForRow(row) {
var modeInput = row.querySelector('[name="schedule_mode[]"]'); var fields = getScheduleRowFields(row);
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]'); var mode = String(fields.mode ? fields.mode.value : 'always');
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]'); var days = formatDays(fields.daysJson && fields.daysJson.value ? fields.daysJson.value : '[]');
var startTime = row.querySelector('[name="schedule_start_time[]"]');
var endTime = row.querySelector('[name="schedule_end_time[]"]');
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
var mode = String(modeInput ? modeInput.value : 'always');
var days = formatDays(daysJson && daysJson.value ? daysJson.value : '[]');
if (mode === 'dates') { if (mode === 'dates') {
if (startDatetime && endDatetime && startDatetime.value && endDatetime.value) { if (fields.startDatetime && fields.endDatetime && fields.startDatetime.value && fields.endDatetime.value) {
return 'Dates: ' + startDatetime.value.replace('T', ' ') + ' to ' + endDatetime.value.replace('T', ' '); return 'Dates: ' + fields.startDatetime.value.replace('T', ' ') + ' to ' + fields.endDatetime.value.replace('T', ' ');
} }
return 'Dates: not set'; return 'Dates: not set';
} }
if (mode === 'times') { if (mode === 'times') {
if (days && startTime && endTime && startTime.value && endTime.value) { if (days && fields.startTime && fields.endTime && fields.startTime.value && fields.endTime.value) {
return 'Times: ' + days + ' ' + startTime.value.slice(0, 5) + '-' + endTime.value.slice(0, 5); return 'Times: ' + days + ' ' + fields.startTime.value.slice(0, 5) + '-' + fields.endTime.value.slice(0, 5);
} }
return 'Times: not set'; return 'Times: not set';
} }
@@ -670,14 +806,7 @@
slide.isAssigned = isAssigned; slide.isAssigned = isAssigned;
if (card) { updateSlidePickerCardState(card, isAssigned, isCanvasMismatch);
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
card.disabled = isAssigned || isCanvasMismatch;
card.classList.toggle('is-assigned', isAssigned);
card.classList.toggle('is-mismatch', isCanvasMismatch);
card.classList.toggle('is-hidden', false);
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
}
if (isAssigned) { if (isAssigned) {
slidePickerSelection.delete(slideId); slidePickerSelection.delete(slideId);
if (card) { if (card) {
@@ -745,31 +874,26 @@
} }
function setRowSchedule(row, values) { function setRowSchedule(row, values) {
var modeInput = row.querySelector('[name="schedule_mode[]"]'); var fields = getScheduleRowFields(row);
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
var startTime = row.querySelector('[name="schedule_start_time[]"]');
var endTime = row.querySelector('[name="schedule_end_time[]"]');
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
var summary = row.querySelector('.playlist-schedule-summary'); var summary = row.querySelector('.playlist-schedule-summary');
if (modeInput) { if (fields.mode) {
modeInput.value = values.schedule_mode || 'always'; fields.mode.value = values.schedule_mode || 'always';
} }
if (startDatetime) { if (fields.startDatetime) {
startDatetime.value = values.schedule_start_datetime || ''; fields.startDatetime.value = values.schedule_start_datetime || '';
} }
if (endDatetime) { if (fields.endDatetime) {
endDatetime.value = values.schedule_end_datetime || ''; fields.endDatetime.value = values.schedule_end_datetime || '';
} }
if (startTime) { if (fields.startTime) {
startTime.value = values.schedule_start_time || ''; fields.startTime.value = values.schedule_start_time || '';
} }
if (endTime) { if (fields.endTime) {
endTime.value = values.schedule_end_time || ''; fields.endTime.value = values.schedule_end_time || '';
} }
if (daysJson) { if (fields.daysJson) {
daysJson.value = values.schedule_days_json || '[]'; fields.daysJson.value = values.schedule_days_json || '[]';
} }
if (summary) { if (summary) {
summary.textContent = values.summary || scheduleSummaryForRow(row); summary.textContent = values.summary || scheduleSummaryForRow(row);
@@ -799,40 +923,16 @@
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || '')); row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || '')); row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration))); row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
var durationActionMarkup = Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video' var durationActionMarkup = buildVideoDurationButtonMarkup(
? '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (values.useVideoDuration ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (values.useVideoDuration ? 'true' : 'false') + '">' + (values.useVideoDuration ? 'Use Video Duration' : 'Use video duration') + '</button>' Boolean(values.useVideoDuration),
: ''; Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
);
row.innerHTML = '' + row.innerHTML = '' +
'<td class="playlist-order-cell" data-label="Order">' + buildPlaylistOrderCellMarkup() +
'<div class="playlist-order-cell-inner">' + buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) +
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' + buildPlaylistScheduleCellMarkup(values) +
'<span class="playlist-order-number"></span>' + buildPlaylistDurationCellMarkup(values, durationActionMarkup) +
'</div>' + buildPlaylistActionsCellMarkup(playlistId, rowKey);
'</td>' +
'<td data-label="Slide">' +
'<div class="playlist-slide-cell">' +
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
'</div>' +
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
'<td>' +
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
'</td>' +
'<td><div class="playlist-duration-field"><input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
durationActionMarkup +
'</div></td>' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
'</div></td>';
return row; return row;
} }
@@ -919,11 +1019,7 @@
row.setAttribute('data-video-duration-previous-value', String(input.value || '')); row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
} }
if (button) { syncVideoDurationButtonState(button, isPressed);
button.classList.toggle('active', Boolean(isPressed));
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
button.textContent = isPressed ? 'Use Video Duration' : 'Use video duration';
}
if (input) { if (input) {
if (!isPressed && row) { if (!isPressed && row) {
@@ -997,7 +1093,7 @@
if (button) { if (button) {
button.disabled = true; button.disabled = true;
button.textContent = 'Loading...'; button.textContent = VIDEO_DURATION_LOADING_LABEL;
} }
try { try {
@@ -1018,7 +1114,7 @@
} finally { } finally {
if (button) { if (button) {
button.disabled = false; button.disabled = false;
button.textContent = button.getAttribute('aria-pressed') === 'true' ? 'Use Video Duration' : 'Use video duration'; syncVideoDurationButtonState(button, button.getAttribute('aria-pressed') === 'true');
} }
} }
} }
@@ -1032,7 +1128,7 @@
var removeButton = event.target.closest('[data-playlist-remove-row]'); var removeButton = event.target.closest('[data-playlist-remove-row]');
var scheduleButton = event.target.closest('[data-schedule-config]'); var scheduleButton = event.target.closest('[data-schedule-config]');
var durationButton = event.target.closest('[data-use-video-duration]'); var durationButton = event.target.closest('[data-use-video-duration]');
var row = event.target.closest('tr[data-playlist-slide-row]'); var row = getPlaylistSlideRow(event.target);
if (!row) { if (!row) {
return; return;
@@ -1048,13 +1144,13 @@
if (scheduleButton) { if (scheduleButton) {
event.preventDefault(); event.preventDefault();
if (typeof window.openScheduleModal === 'function') { if (typeof window.openScheduleModal === 'function') {
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || '')); window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(getPlaylistSlideRowKey(row)));
} }
return; return;
} }
if (durationButton) { if (durationButton) {
var rowKey = row.getAttribute('data-row-key'); var rowKey = getPlaylistSlideRowKey(row);
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey; var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey; var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
@@ -1074,29 +1170,23 @@
}); });
tbody.addEventListener('mousedown', function (event) { tbody.addEventListener('mousedown', function (event) {
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null; var row = getPlaylistSlideRow(event.target);
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null; var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null; var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null; var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
if (row && !dragHandle) { if (row && !dragHandle) {
event.stopPropagation(); event.stopPropagation();
} }
if (durationInput) { if (durationInput) {
lastDurationPointerDown = { lastDurationPointerDown = getDurationPointerState(event.target, 'input');
type: 'input',
rowKey: String(event.target.closest('tr[data-playlist-slide-row]') && event.target.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
};
event.stopPropagation(); event.stopPropagation();
return; return;
} }
if (durationToggle) { if (durationToggle) {
lastDurationPointerDown = { lastDurationPointerDown = getDurationPointerState(durationToggle, 'button');
type: 'button',
rowKey: String(durationToggle.closest('tr[data-playlist-slide-row]') && durationToggle.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
};
return; return;
} }
@@ -1105,22 +1195,16 @@
tbody.addEventListener('mouseup', function (event) { tbody.addEventListener('mouseup', function (event) {
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null; var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null; var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null; var row = getPlaylistSlideRow(event.target);
if (durationInput) { if (durationInput) {
lastDurationPointerUp = { lastDurationPointerUp = getDurationPointerState(row, 'input');
type: 'input',
rowKey: String(row && row.getAttribute('data-row-key') || '')
};
return; return;
} }
if (durationToggle) { if (durationToggle) {
lastDurationPointerUp = { lastDurationPointerUp = getDurationPointerState(row, 'button');
type: 'button',
rowKey: String(row && row.getAttribute('data-row-key') || '')
};
return; return;
} }
@@ -1129,7 +1213,7 @@
tbody.addEventListener('input', function (event) { tbody.addEventListener('input', function (event) {
if (event.target && event.target.matches('[name="duration_seconds[]"]')) { if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
var editedRow = event.target.closest('tr[data-playlist-slide-row]'); var editedRow = getPlaylistSlideRow(event.target);
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null; var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null; var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true'); var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
+8 -239
View File
@@ -1,247 +1,16 @@
(function () { (function () {
var REFRESH_INTERVAL_MS = 10000; if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
var pathname = window.location.pathname.replace(/\/$/, '');
var stateNode = document.getElementById('background-tasks-state');
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') {
return; return;
} }
function getPageUrl(href) { try {
try { var url = new URL(window.location.href);
return new URL(href, window.location.href); if (!url.searchParams.has('message')) {
} catch (_error) {
return null;
}
}
function updateLocalDateTimes(root) {
if (typeof window.initLocalDateTimes === 'function') {
window.initLocalDateTimes(root || document);
}
}
function replaceSectionFromDocument(sectionName, responseDocument) {
var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]');
var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]');
if (!currentCard || !nextCard) {
return false;
}
currentCard.outerHTML = nextCard.outerHTML;
updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]'));
return true;
}
function replaceSummaryFromDocument(responseDocument) {
var currentSummary = document.getElementById('background-tasks-summary');
var nextSummary = responseDocument.getElementById('background-tasks-summary');
if (!currentSummary || !nextSummary) {
return false;
}
currentSummary.outerHTML = nextSummary.outerHTML;
return true;
}
function replaceFiltersFromDocument(responseDocument) {
var currentFilters = document.getElementById('background-tasks-filters');
var nextFilters = responseDocument.getElementById('background-tasks-filters');
if (!currentFilters || !nextFilters) {
return false;
}
currentFilters.outerHTML = nextFilters.outerHTML;
return true;
}
function replaceStateFromDocument(responseDocument) {
var currentState = document.getElementById('background-tasks-state');
var nextState = responseDocument.getElementById('background-tasks-state');
if (!currentState || !nextState) {
return false;
}
currentState.outerHTML = nextState.outerHTML;
stateNode = document.getElementById('background-tasks-state');
currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion;
return true;
}
function applyPageResponse(responseDocument, sectionNames) {
var updated = false;
if (replaceStateFromDocument(responseDocument)) {
updated = true;
}
if (replaceSummaryFromDocument(responseDocument)) {
updated = true;
}
if (replaceFiltersFromDocument(responseDocument)) {
updated = true;
}
sectionNames.forEach(function (sectionName) {
if (replaceSectionFromDocument(sectionName, responseDocument)) {
updated = true;
}
});
return updated;
}
function loadPage(nextUrl, options) {
var url = getPageUrl(nextUrl);
var sectionNames = options && options.sectionNames ? options.sectionNames : [];
var replaceHistory = Boolean(options && options.replaceHistory);
if (!url) {
return; return;
} }
url.searchParams.delete('message');
fetch(url.toString(), { window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
method: 'GET', } catch (_error) {
headers: { // Ignore URL cleanup failures.
'Accept': 'text/html, application/xhtml+xml',
'X-Requested-With': 'XMLHttpRequest'
},
credentials: 'same-origin',
cache: 'no-store'
}).then(function (response) {
if (!response.ok) {
throw new Error('Unable to load background tasks page.');
}
return response.text();
}).then(function (text) {
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) {
return;
}
if (replaceHistory) {
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
} else {
window.history.pushState({}, document.title, url.pathname + url.search + url.hash);
}
}).catch(function (_error) {
window.location.assign(url.pathname + url.search + url.hash);
});
} }
function stripMessageParameter() {
try {
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);
}()); }());
File diff suppressed because it is too large Load Diff
-242
View File
@@ -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;
}());
+170
View File
@@ -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;
}());
+197
View File
@@ -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();
}());
-606
View File
@@ -1,606 +0,0 @@
(function () {
var dataElement = document.getElementById('template-editor-data');
if (!dataElement) {
return;
}
var utils = window.templateDesignerUtils || {};
var templateData = {};
try {
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
} catch (_error) {
templateData = {};
}
var existingRegions = Array.isArray(templateData.regions)
? templateData.regions
: (Array.isArray(templateData) ? templateData : []);
var stage = document.getElementById('designer-stage');
var overlay = document.getElementById('designer-overlay');
var regionList = document.getElementById('region-list');
var regionSelect = document.getElementById('region-select');
var canvasSizeSelect = document.getElementById('canvas-size-select');
var canvasSizeIdInput = document.getElementById('canvas-size-id');
var canvasSizeSummary = document.getElementById('canvas-size-summary');
var canvasWidthInput = document.getElementById('canvas-width');
var canvasHeightInput = document.getElementById('canvas-height');
var backgroundInput = document.getElementById('background-image');
var backgroundColorInput = document.getElementById('background-color');
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
var addRegionButton = document.getElementById('add-region-button');
var regionAddModal = document.getElementById('region-add-modal');
var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form');
var draft = null;
var selectedIndex = -1;
var overlayRenderFrame = 0;
function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
}
function valueOrDefault(value, fallback) {
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
}
function getCanvasSize() {
return {
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
height: Math.max(1, Number(canvasHeightInput.value || 1080))
};
}
function clamp(value, min, max) {
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
}
function getCards() {
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
}
function cardAt(index) {
return getCards()[index] || null;
}
function getRegionName(card) {
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
}
function syncRegionIdentity(card, value) {
if (utils.syncRegionIdentity) {
utils.syncRegionIdentity(card, value);
return;
}
var next = String(value || '').trim();
card.querySelector('[name="region_name[]"]').value = next;
card.querySelector('[name="region_key[]"]').value = next;
card.querySelector('[name="region_label[]"]').value = next;
}
function validateRegionNames() {
var cards = getCards();
var names = {};
var hasDuplicate = false;
cards.forEach(function (card) {
var input = card.querySelector('[name="region_name[]"]');
if (!input) {
return;
}
var normalized = String(input.value || '').trim().toLowerCase();
if (!normalized) {
input.setCustomValidity('Region name is required.');
return;
}
if (!names[normalized]) {
names[normalized] = [];
}
names[normalized].push(input);
});
Object.keys(names).forEach(function (key) {
var inputs = names[key];
if (inputs.length > 1) {
hasDuplicate = true;
inputs.forEach(function (input) {
input.setCustomValidity('Region names must be unique on this template.');
});
} else {
inputs[0].setCustomValidity('');
}
});
return !hasDuplicate;
}
function readCard(card) {
return utils.readCard ? utils.readCard(card) : {
region_key: getRegionName(card),
label: getRegionName(card),
region_type: card.querySelector('[name="region_type[]"]').value,
font_family: card.querySelector('[name="font_family[]"]').value,
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
};
}
function writeCard(card, values) {
if (utils.writeCard) {
utils.writeCard(card, values);
return;
}
if (values.region_name !== undefined) {
syncRegionIdentity(card, values.region_name);
} else if (values.region_key !== undefined) {
syncRegionIdentity(card, values.region_key);
} else if (values.label !== undefined) {
syncRegionIdentity(card, values.label);
}
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
}
function getOverlayRect() {
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
}
function toCanvasPoint(event) {
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
x: 0,
y: 0
};
}
function canvasRectToPixels(region) {
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
left: 0,
top: 0,
width: 0,
height: 0
};
}
function updateAspectRatio() {
var size = getCanvasSize();
stage.style.aspectRatio = size.width + ' / ' + size.height;
}
function updateCanvasSizeSummary() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
canvasSizeSummary.textContent = option ? option.textContent : '';
}
function updateCanvasSizeLock() {
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
var locked = lockOnExistingTemplate && getCards().length > 0;
canvasSizeSelect.disabled = locked;
canvasSizeSummary.classList.toggle('is-locked', locked);
}
function syncCanvasSizeSelection() {
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
if (!option) {
return;
}
if (canvasSizeIdInput) {
canvasSizeIdInput.value = option.value;
}
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
updateAspectRatio();
updateCanvasSizeSummary();
}
function updateBackgroundPreview(file) {
if (!file) {
return;
}
if (removeBackgroundFlag) {
removeBackgroundFlag.checked = false;
}
var reader = new FileReader();
reader.onload = function () {
backgroundPreview.src = reader.result;
backgroundPreview.style.display = 'block';
backgroundEmpty.style.display = 'none';
};
reader.readAsDataURL(file);
}
function updateStageBackgroundColor() {
if (!stage) {
return;
}
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
var chip = card.querySelector('[data-region-chip]');
var title = card.querySelector('[data-region-title]');
var nameInput = card.querySelector('[name="region_name[]"]');
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
var regionTypeInput = card.querySelector('[name="region_type[]"]');
var regionKeyInput = card.querySelector('[name="region_key[]"]');
var regionLabelInput = card.querySelector('[name="region_label[]"]');
if (title) {
title.textContent = region.label || region.region_key || 'Region';
}
if (chip) {
chip.textContent = getRegionChipLabel(region.region_type);
}
if (nameInput) {
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
}
if (regionKeyInput) {
regionKeyInput.value = region.region_key || region.label || '';
}
if (regionLabelInput) {
regionLabelInput.value = region.label || region.region_key || '';
}
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
}
function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region';
var cards = getCards();
var index = cards.indexOf(card);
var title = card.querySelector('.template-field-head strong');
if (title) {
title.textContent = label;
}
if (index >= 0 && regionSelect.options[index]) {
regionSelect.options[index].textContent = label;
}
}
function makeRegionCard(region) {
var card;
if (regionCardTemplate && regionCardTemplate.content) {
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
} else {
card = document.createElement('div');
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
}
populateRegionCard(card, region);
var nameInput = card.querySelector('[name="region_name[]"]');
nameInput.addEventListener('input', function () {
syncRegionIdentity(card, nameInput.value);
updateRegionLabel(card);
validateRegionNames();
renderRegionSidebar();
renderOverlay();
});
card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
return;
}
setSelected(getCards().indexOf(card));
});
card.querySelector('.remove-region').addEventListener('click', function (event) {
event.preventDefault();
card.remove();
if (!getCards().length) {
selectedIndex = -1;
} else if (selectedIndex >= getCards().length) {
selectedIndex = getCards().length - 1;
}
renderRegionSidebar();
renderOverlay();
});
return card;
}
function renderRegionList(initialRegions) {
regionList.innerHTML = '';
initialRegions.forEach(function (region) {
regionList.appendChild(makeRegionCard(region));
});
}
function renderRegionSidebar() {
var cards = getCards();
regionSelect.innerHTML = '';
if (!cards.length) {
regionSelect.disabled = true;
regionList.innerHTML = '';
updateCanvasSizeLock();
return;
}
regionSelect.disabled = false;
if (selectedIndex < 0 || selectedIndex >= cards.length) {
selectedIndex = 0;
}
cards.forEach(function (card, index) {
var option = document.createElement('option');
option.value = String(index);
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
if (index === selectedIndex) {
option.selected = true;
}
regionSelect.appendChild(option);
card.hidden = index !== selectedIndex;
});
regionSelect.value = String(selectedIndex);
updateCanvasSizeLock();
validateRegionNames();
}
function renderOverlay() {
var cards = getCards();
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
var regions = cards.map(readCard);
regionsJsonInput.value = JSON.stringify(regions);
overlay.innerHTML = regions.map(function (region, index) {
var box = canvasRectToPixels(region);
var selected = index === selectedNow ? ' selected' : '';
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
}).join('');
if (draft) {
var rect = getOverlayRect();
var size = getCanvasSize();
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
}
}
function requestOverlayRender() {
if (overlayRenderFrame) {
return;
}
overlayRenderFrame = window.requestAnimationFrame(function () {
overlayRenderFrame = 0;
renderOverlay();
});
}
function render() {
updateAspectRatio();
renderRegionSidebar();
renderOverlay();
}
function setSelected(index) {
var cards = getCards();
if (!cards.length) {
selectedIndex = -1;
} else if (index < 0) {
selectedIndex = 0;
} else {
selectedIndex = clamp(index, 0, cards.length - 1);
}
renderRegionSidebar();
renderOverlay();
}
function addRegion(region) {
var hint = regionList.querySelector('.muted');
if (hint) {
hint.remove();
}
regionList.appendChild(makeRegionCard(region));
setSelected(getCards().length - 1);
}
function openAddRegionModal() {
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
return;
}
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
}
function createDefaultRegion(type) {
var count = getCards().length + 1;
var name = 'region_' + count;
return {
region_key: name,
label: name,
region_type: type,
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
x: 80,
y: 80,
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
z_index: 1
};
}
function clampRegion(region) {
var size = getCanvasSize();
var minSize = 12;
var x = clamp(region.x, 0, size.width - minSize);
var y = clamp(region.y, 0, size.height - minSize);
var width = Math.max(minSize, region.width);
var height = Math.max(minSize, region.height);
if (x + width > size.width) {
width = size.width - x;
}
if (y + height > size.height) {
height = size.height - y;
}
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
}
function startDraw(event) {
var start = toCanvasPoint(event);
draft = { start: start, end: start };
renderOverlay();
function moveHandler(moveEvent) {
draft.end = toCanvasPoint(moveEvent);
requestOverlayRender();
}
function upHandler(upEvent) {
draft.end = toCanvasPoint(upEvent);
var width = Math.abs(draft.end.x - draft.start.x);
var height = Math.abs(draft.end.y - draft.start.y);
if (width >= 8 && height >= 8) {
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
}
draft = null;
requestOverlayRender();
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function startMove(index, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
writeCard(cardAt(index), { x: next.x, y: next.y });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
function resizeFromHandle(index, dir, event) {
var startPoint = toCanvasPoint(event);
var startRegion = readCard(cardAt(index));
function moveHandler(moveEvent) {
var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
next = clampRegion(next);
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
requestOverlayRender();
}
function upHandler() {
document.removeEventListener('mousemove', moveHandler);
document.removeEventListener('mouseup', upHandler);
}
document.addEventListener('mousemove', moveHandler);
document.addEventListener('mouseup', upHandler);
}
if (addRegionButton && regionAddModal) {
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
addRegionButton.addEventListener('click', function () {
openAddRegionModal();
});
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
button.addEventListener('click', function () {
var regionType = button.getAttribute('data-add-region-type');
addRegion(createDefaultRegion(regionType));
if (window.bootstrap && window.bootstrap.Modal) {
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
}
});
});
}
backgroundInput.addEventListener('change', function () {
var file = backgroundInput.files && backgroundInput.files[0];
if (file) {
updateBackgroundPreview(file);
}
});
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
});
}
if (backgroundColorInput) {
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
}
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
overlay.addEventListener('mousedown', function (event) {
var rect = event.target.closest('.designer-rect');
if (rect) {
var index = Number(rect.getAttribute('data-index'));
var handle = event.target.closest('.resize-handle');
event.preventDefault();
setSelected(index);
if (handle) {
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
} else {
startMove(index, event);
}
return;
}
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
return;
}
event.preventDefault();
setSelected(-1);
startDraw(event);
});
if (templateForm) {
templateForm.addEventListener('formdata', function (event) {
if (!validateRegionNames()) {
event.preventDefault();
return;
}
syncCanvasSizeSelection();
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
});
templateForm.addEventListener('submit', function () {
if (!validateRegionNames()) {
return;
}
syncCanvasSizeSelection();
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
});
}
renderRegionList(existingRegions);
syncCanvasSizeSelection();
updateStageBackgroundColor();
render();
})();
@@ -36,10 +36,26 @@
var regionCardTemplate = document.getElementById('region-card-template'); var regionCardTemplate = document.getElementById('region-card-template');
var regionsJsonInput = document.getElementById('regions-json'); var regionsJsonInput = document.getElementById('regions-json');
var templateForm = document.getElementById('template-form'); var templateForm = document.getElementById('template-form');
var templateRegionUsageElement = document.getElementById('template-region-usage');
var regionUsageSet = new Set();
var draft = null; var draft = null;
var selectedIndex = -1; var selectedIndex = -1;
var overlayRenderFrame = 0; var overlayRenderFrame = 0;
try {
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
if (Array.isArray(regionUsageData)) {
regionUsageData.forEach(function (regionKey) {
var normalized = String(regionKey || '').trim();
if (normalized) {
regionUsageSet.add(normalized);
}
});
}
} catch (_error) {
regionUsageSet = new Set();
}
function escapeHtml(value) { function escapeHtml(value) {
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value); return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
} }
@@ -357,6 +373,21 @@
updateRegionLockBadge(card); updateRegionLockBadge(card);
} }
function setRegionRemovalState(card) {
var removeButton = card.querySelector('[data-region-remove-button]');
if (!removeButton) {
return;
}
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
var regionDeletionLocked = regionUsageSet.has(regionKey);
removeButton.disabled = regionDeletionLocked;
if (regionDeletionLocked) {
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
} else {
removeButton.removeAttribute('title');
}
}
function updateRegionLabel(card) { function updateRegionLabel(card) {
var label = getRegionName(card) || 'Region'; var label = getRegionName(card) || 'Region';
var cards = getCards(); var cards = getCards();
@@ -379,6 +410,8 @@
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3'; card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
} }
populateRegionCard(card, region); populateRegionCard(card, region);
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
setRegionRemovalState(card);
var nameInput = card.querySelector('[name="region_name[]"]'); var nameInput = card.querySelector('[name="region_name[]"]');
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]'); var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
var widthInput = card.querySelector('[name="region_width[]"]'); var widthInput = card.querySelector('[name="region_width[]"]');
@@ -415,12 +448,12 @@
}); });
} }
card.addEventListener('click', function (event) { card.addEventListener('click', function (event) {
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) { if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
return; return;
} }
setSelected(getCards().indexOf(card)); setSelected(getCards().indexOf(card));
}); });
card.querySelector('.remove-region').addEventListener('click', function (event) { card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
event.preventDefault(); event.preventDefault();
card.remove(); card.remove();
if (!getCards().length) { if (!getCards().length) {
@@ -523,7 +556,9 @@
if (hint) { if (hint) {
hint.remove(); hint.remove();
} }
regionList.appendChild(makeRegionCard(region)); var card = makeRegionCard(region);
setRegionRemovalState(card);
regionList.appendChild(card);
setSelected(getCards().length - 1); setSelected(getCards().length - 1);
} }
@@ -600,8 +635,12 @@
var currentPoint = toCanvasPoint(moveEvent); var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x; var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y; var dy = currentPoint.y - startPoint.y;
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height }); var size = getCanvasSize();
writeCard(cardAt(index), { x: next.x, y: next.y }); var maxX = Math.max(0, size.width - startRegion.width);
var maxY = Math.max(0, size.height - startRegion.height);
var nextX = clamp(startRegion.x + dx, 0, maxX);
var nextY = clamp(startRegion.y + dy, 0, maxY);
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
requestOverlayRender(); requestOverlayRender();
} }
function upHandler() { function upHandler() {
@@ -617,6 +656,24 @@
var startRegion = readCard(cardAt(index)); var startRegion = readCard(cardAt(index));
var lockRatio = normalizeLockRatio(startRegion.lock_ratio); var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
var aspect = lockRatio ? parseLockRatio(lockRatio) : null; var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
var canvasSize = getCanvasSize();
var minSize = 12;
function clampResizeDelta(dx, dy) {
if (dir.indexOf('w') !== -1) {
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
}
if (dir.indexOf('e') !== -1) {
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
}
if (dir.indexOf('n') !== -1) {
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
}
if (dir.indexOf('s') !== -1) {
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
}
return { dx: dx, dy: dy };
}
function fitFromWidth(width) { function fitFromWidth(width) {
var nextWidth = Math.max(12, Math.round(width)); var nextWidth = Math.max(12, Math.round(width));
@@ -638,6 +695,9 @@
var currentPoint = toCanvasPoint(moveEvent); var currentPoint = toCanvasPoint(moveEvent);
var dx = currentPoint.x - startPoint.x; var dx = currentPoint.x - startPoint.x;
var dy = currentPoint.y - startPoint.y; var dy = currentPoint.y - startPoint.y;
var constrainedDelta = clampResizeDelta(dx, dy);
dx = constrainedDelta.dx;
dy = constrainedDelta.dy;
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height }; var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
if (aspect) { if (aspect) {
if (dir === 'e') { if (dir === 'e') {
@@ -748,7 +808,6 @@
} }
event.preventDefault(); event.preventDefault();
setSelected(-1); setSelected(-1);
startDraw(event);
}); });
if (templateForm) { if (templateForm) {
templateForm.addEventListener('formdata', function (event) { templateForm.addEventListener('formdata', function (event) {
-118
View File
@@ -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();
}());
+94
View File
@@ -0,0 +1,94 @@
(function () {
function escapeHtml(value) {
return String(value ?? '')
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function formatDashboardDate(value) {
if (!value) {
return '';
}
var date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: '2-digit',
year: 'numeric',
hour: 'numeric',
minute: '2-digit',
second: '2-digit'
}).format(date);
}
function getClientRowKey(client) {
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
}
function getClientDisplayName(client) {
if (client && client.client_name) {
return String(client.client_name).trim();
}
var clientId = String(client && client.clientId ? client.clientId : '').trim();
if (clientId) {
return clientId;
}
return '';
}
function setButtonVariant(button, classesToRemove, classToAdd) {
if (!button) {
return;
}
if (button.classList) {
classesToRemove.forEach(function (className) {
button.classList.remove(className);
});
if (classToAdd) {
button.classList.add(classToAdd);
}
return;
}
var className = String(button.className || '');
classesToRemove.forEach(function (removeClass) {
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
});
if (classToAdd) {
className += ' ' + classToAdd;
}
button.className = className.replace(/\s+/g, ' ').trim();
}
function normalizeDisplayIp(value) {
var ip = String(value || '').trim();
if (!ip) {
return '';
}
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
return ip.slice(7).trim();
}
return ip;
}
window.webUiHelpers = {
escapeHtml: escapeHtml,
formatDashboardDate: formatDashboardDate,
getClientRowKey: getClientRowKey,
getClientDisplayName: getClientDisplayName,
setButtonVariant: setButtonVariant,
normalizeDisplayIp: normalizeDisplayIp
};
}());
+70 -6
View File
@@ -104,6 +104,55 @@ module.exports = function registerAdminContentRoutes(app, deps) {
} }
} }
async function fetchTemplateSlideCount(templateId) {
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM slides WHERE template_id = ?', [templateId]);
return Number(rows[0] && rows[0].slide_count) || 0;
}
async function fetchTemplateRegionUsage(template) {
const [slides] = await pool.query('SELECT content_json FROM slides WHERE template_id = ?', [template.id]);
const usage = new Set();
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
.map((region) => String(region && region.region_key || '').trim())
.filter(Boolean));
if (!regionKeys.size || !slides.length) {
return [];
}
slides.forEach((slide) => {
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
if (!content || typeof content !== 'object') {
return;
}
regionKeys.forEach((regionKey) => {
if (Object.prototype.hasOwnProperty.call(content, regionKey)) {
usage.add(regionKey);
}
});
});
return Array.from(usage);
}
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
return (Array.isArray(existingRegions) ? existingRegions : [])
.map((region) => String(region && region.region_key || '').trim())
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
}
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
if (!removedRegionKeys.length) {
return '';
}
const usedRegionKeys = Array.isArray(template && template.region_usage) ? template.region_usage : [];
const blockedKeys = removedRegionKeys.filter((regionKey) => usedRegionKeys.includes(regionKey));
return blockedKeys.length > 0 ? 'This region is still used by one or more slides.' : '';
}
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) { function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') { if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
return Promise.resolve(null); return Promise.resolve(null);
@@ -159,10 +208,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderSlidesPage({ res.send(pages.renderSlidesPage({
slides: data.slides || [], slides: data.slides || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -364,10 +416,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) { app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderTemplatesPage({ res.send(pages.renderTemplatesPage({
templates: data.templates || [], templates: data.templates || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -423,6 +478,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (!template) { if (!template) {
return res.status(404).send('Template not found'); return res.status(404).send('Template not found');
} }
template.region_usage = await fetchTemplateRegionUsage(template);
const sizeData = await common.fetchCanvasSizesData(pool); const sizeData = await common.fetchCanvasSizesData(pool);
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser)); res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
} catch (error) { } catch (error) {
@@ -436,11 +492,16 @@ module.exports = function registerAdminContentRoutes(app, deps) {
if (!template) { if (!template) {
return res.status(404).send('Template not found'); return res.status(404).send('Template not found');
} }
template.region_usage = await fetchTemplateRegionUsage(template);
const existingUploadRefs = collectUploadReferencesFromTemplate(template); const existingUploadRefs = collectUploadReferencesFromTemplate(template);
const payload = await common.buildTemplatePayload(pool, req, template); const payload = await common.buildTemplatePayload(pool, req, template);
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) { if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.')); return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
} }
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
if (regionDeleteBlockMessage) {
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent(regionDeleteBlockMessage));
}
const nextUploadRefs = collectUploadReferencesFromPayload(payload); const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id); const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req); const actorId = getAuditUserId(req);
@@ -505,10 +566,13 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) { app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderCanvasSizesPage({ res.send(pages.renderCanvasSizesPage({
canvasSizes: data.canvasSizes || [], canvasSizes: data.canvasSizes || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+10 -4
View File
@@ -199,7 +199,10 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) { app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const apiSources = (data.apiSources || []).map(function (apiSource) { const apiSources = (data.apiSources || []).map(function (apiSource) {
return Object.assign({}, apiSource, { return Object.assign({}, apiSource, {
intervalLabel: apiSource.update_interval_unit === 'seconds' intervalLabel: apiSource.update_interval_unit === 'seconds'
@@ -211,7 +214,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
}); });
res.send(pages.renderApiSourcesPage({ res.send(pages.renderApiSourcesPage({
apiSources: apiSources, apiSources: apiSources,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'API sources', 'API source pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); }, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -381,10 +384,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) { app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderRssFeedsPage({ res.send(pages.renderRssFeedsPage({
rssFeeds: data.rssFeeds || [], rssFeeds: data.rssFeeds || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+12 -3
View File
@@ -36,12 +36,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
? req.body.blackout ? req.body.blackout
: req.query.blackout; : req.query.blackout;
const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused')
? req.body.paused
: req.query.paused;
if (!command) { if (!command) {
return res.status(400).json({ error: 'Command is required' }); return res.status(400).json({ error: 'Command is required' });
} }
if (command !== 'reload' && command !== 'blackout') { if (command !== 'reload' && command !== 'blackout' && command !== 'pause') {
return res.status(400).json({ error: 'Unsupported command' }); return res.status(400).json({ error: 'Unsupported command' });
} }
@@ -56,7 +59,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
command: 'blackout', command: 'blackout',
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
} }
: 'reload'; : command === 'pause'
? {
command: 'pause',
paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false
}
: 'reload';
const sentCount = await notifyPlayerScreens(slugs, payload); const sentCount = await notifyPlayerScreens(slugs, payload);
await broadcastDashboardState(); await broadcastDashboardState();
@@ -65,7 +73,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
ok: true, ok: true,
command: command, command: command,
sent: sentCount, sent: sentCount,
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined,
paused: command === 'pause' ? Boolean(payload.paused) : undefined
}); });
} catch (error) { } catch (error) {
next(error); next(error);
+5 -2
View File
@@ -162,10 +162,13 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) { app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderRbacPage({ res.send(pages.renderRbacPage({
roles: data.roles || [], roles: data.roles || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'roles', 'Role pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+5 -2
View File
@@ -57,7 +57,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
app.get('/users', requirePermission('users.read'), async function (req, res, next) { app.get('/users', requirePermission('users.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
const mappedUsers = (data.users || []).map(function (user) { const mappedUsers = (data.users || []).map(function (user) {
return Object.assign({}, user, { return Object.assign({}, user, {
isCurrentUser: Number(user.id) === Number(req.currentUser.id), isCurrentUser: Number(user.id) === Number(req.currentUser.id),
@@ -68,7 +71,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
}); });
res.send(pages.renderUsersPage({ res.send(pages.renderUsersPage({
users: mappedUsers, users: mappedUsers,
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'users', 'Users pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'Users pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
@@ -1,10 +1,11 @@
const { renderView } = require('../../view'); const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac'); const { hasAnyPermission } = require('../../../rbac');
const { buildPaginationPages } = require('../../lib/pagination'); const { buildPagination, buildQueryString } = require('../../lib/pagination');
const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query');
const TASKS_PER_PAGE = 10; const TASKS_PER_PAGE = 10;
const QUEUE_PAGE_URL = '/settings/background-tasks'; const QUEUE_PAGE_URL = '/settings/tasks-background';
const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled'; const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
function formatIntervalLabel(intervalMs) { function formatIntervalLabel(intervalMs) {
const value = Math.max(1, Number(intervalMs) || 0); const value = Math.max(1, Number(intervalMs) || 0);
@@ -19,11 +20,6 @@ function formatIntervalLabel(intervalMs) {
return `${value} ms`; return `${value} ms`;
} }
function parsePageNumber(value) {
const pageNumber = Math.floor(Number(value) || 1);
return Math.max(1, pageNumber);
}
function normalizeSourceFilter(sourceType, sourceId) { function normalizeSourceFilter(sourceType, sourceId) {
const normalizedSourceType = String(sourceType || '').trim(); const normalizedSourceType = String(sourceType || '').trim();
const normalizedSourceId = Math.floor(Number(sourceId) || 0); const normalizedSourceId = Math.floor(Number(sourceId) || 0);
@@ -38,48 +34,6 @@ function normalizeSourceFilter(sourceType, sourceId) {
}; };
} }
function buildQueryString(query) {
const searchParams = new URLSearchParams();
['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) {
const value = query && query[key];
if (value === undefined || value === null || value === '') {
return;
}
searchParams.set(key, String(value));
});
const queryString = searchParams.toString();
return queryString ? `?${queryString}` : '';
}
function buildPagination(totalItems, currentPage, pageParam, queryState) {
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
return {
currentPage: safeCurrentPage,
totalPages: totalPages,
totalItems: totalItems,
hasMultiplePages: totalPages > 1,
startItem: totalItems === 0 ? 0 : startIndex + 1,
endItem: endIndex,
hasPrevious: safeCurrentPage > 1,
hasNext: safeCurrentPage < totalPages,
previousUrl: buildQueryString(previousQuery),
nextUrl: buildQueryString(nextQuery),
pages: pages,
pageSize: TASKS_PER_PAGE,
pageParam: normalizedPageParam
};
}
function taskMatchesFilters(task, sourceFilter) { function taskMatchesFilters(task, sourceFilter) {
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) { if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
return true; return true;
@@ -89,6 +43,8 @@ function taskMatchesFilters(task, sourceFilter) {
return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId; return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId;
} }
const taskMatchesSearch = createSearchMatcher;
function formatStatusLabel(status) { function formatStatusLabel(status) {
const normalized = String(status || '').trim().toLowerCase(); const normalized = String(status || '').trim().toLowerCase();
if (!normalized) { if (!normalized) {
@@ -133,25 +89,61 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
function buildQueuePageViewModel(data, message, currentUser) { function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || []; const tasks = (data && data.tasks) || [];
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']); const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
const sort = String(data && data.sort || '').trim();
const direction = normalizeSortDirection(data && data.direction);
const queryState = { const queryState = {
page: parsePageNumber(data && data.page), page: parsePageNumber(data && data.page),
recurringPage: parsePageNumber(data && data.recurringPage),
sourceType: '', sourceType: '',
sourceId: '' sourceId: '',
search: String(data && data.search || '').trim(),
sort: sort,
direction: direction
}; };
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId); const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
queryState.sourceType = sourceFilter.sourceType; queryState.sourceType = sourceFilter.sourceType;
queryState.sourceId = sourceFilter.sourceId; queryState.sourceId = sourceFilter.sourceId;
const filteredTasks = tasks.filter(function (task) { const filteredTasks = tasks.filter(function (task) {
return taskMatchesFilters(task, sourceFilter); return taskMatchesFilters(task, sourceFilter) && taskMatchesSearch(queryState.search, [
'title',
'key',
'category',
'status',
'errorMessage',
function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; }
])(task);
}); });
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState); const sortedTasks = sortRows(filteredTasks, function (task) {
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState); if (sort === 'task') {
const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE); return task && task.title;
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE); }
if (sort === 'category') {
return task && task.category;
}
if (sort === 'status') {
return task && task.status;
}
if (sort === 'queued') {
return task && task.createdAt;
}
if (sort === 'started') {
return task && task.startedAt;
}
if (sort === 'finished') {
return task && task.finishedAt;
}
if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
return task && task.createdAt;
}, direction);
const pagination = buildPagination(sortedTasks.length, queryState.page, 'page', queryState, TASKS_PER_PAGE, 'tasks', 'Background task pages');
const visibleTasks = sortedTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
const statusCards = [ const statusCards = [
{ {
status: 'queued', status: 'queued',
@@ -199,16 +191,14 @@ function buildQueuePageViewModel(data, message, currentUser) {
message: message, message: message,
currentUser: currentUser || null, currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'], scripts: ['js/settings/background-tasks.js'],
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true, backgroundTasksMenuOpen: true,
tasks: visibleTasksWithSources, tasks: visibleTasksWithSources,
summary: summary, summary: summary,
statusCards: statusCards, statusCards: statusCards,
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '', activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }), clearFiltersUrl: QUEUE_PAGE_URL,
hasActiveFilters: hasActiveFilters, hasActiveFilters: hasActiveFilters,
pagination: pagination, pagination: pagination,
recurringPagination: recurringPagination,
canManage: canManage, canManage: canManage,
canClearFinished: tasks.some(function (task) { canClearFinished: tasks.some(function (task) {
return task.status !== 'queued' && task.status !== 'running'; return task.status !== 'queued' && task.status !== 'running';
@@ -220,6 +210,47 @@ function buildScheduledPageViewModel(data, message, currentUser) {
const recurringTasks = (data && data.recurringTasks) || []; const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 }; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 };
const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']); const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']);
const search = String(data && data.search || '').trim();
const sort = String(data && data.sort || '').trim();
const direction = normalizeSortDirection(data && data.direction);
const filteredRecurringTasks = recurringTasks.filter(function (task) {
return taskMatchesSearch(search, [
'title',
'intervalLabel',
'nextRunAt',
'lastRunAt',
'lastStatus',
'lastError',
function (item) { return item && item.metadata && item.metadata.sourceName; },
function (item) { return item && item.metadata && item.metadata.sourceType; },
function (item) { return item && item.metadata && item.metadata.sourceId; }
])(task);
});
const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) {
if (sort === 'task') {
return task && task.title;
}
if (sort === 'interval') {
return task && task.intervalMs;
}
if (sort === 'next_run') {
return task && task.nextRunAt;
}
if (sort === 'last_run') {
return task && task.lastRunAt;
}
if (sort === 'last_result') {
return [task && task.lastStatus, task && task.lastError].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
if (sort === 'source') {
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
return String(value || '').trim();
}).join(' ');
}
return task && task.nextRunAt;
}, direction);
return renderView('settings/tasks-scheduled/index', { return renderView('settings/tasks-scheduled/index', {
title: 'Scheduled tasks', title: 'Scheduled tasks',
@@ -227,17 +258,16 @@ function buildScheduledPageViewModel(data, message, currentUser) {
message: message, message: message,
currentUser: currentUser || null, currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'], scripts: ['js/settings/background-tasks.js'],
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true, backgroundTasksMenuOpen: true,
recurringTasks: recurringTasks.map(function (task) { recurringTasks: sortedRecurringTasks.map(function (task) {
return Object.assign({}, task, { return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs) intervalLabel: formatIntervalLabel(task.intervalMs)
}); });
}), }),
summary: summary, summary: summary,
canManage: canManage, canManage: canManage,
scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0), scheduledCount: Number(summary.scheduledCount || filteredRecurringTasks.length || 0),
recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) }) recurringPagination: buildPagination(sortedRecurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage), search: search, sort: sort, direction: direction })
}); });
} }
+24 -69
View File
@@ -42,65 +42,18 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.'); throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
} }
function buildQueueVersion(tasks, recurringTasks, summary) { app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
return [
'q:' + Number(summary && summary.counts && summary.counts.queued || 0),
'r:' + Number(summary && summary.counts && summary.counts.running || 0),
'f:' + Number(summary && summary.counts && summary.counts.failed || 0),
'c:' + Number(summary && summary.counts && summary.counts.completed || 0),
'a:' + Number(summary && summary.activeCount || 0),
't:' + Number(summary && summary.total || 0),
'tasks:' + (tasks || []).map(function (task) {
return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':');
}).join('|'),
'recurring:' + (recurringTasks || []).map(function (task) {
return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':');
}).join('|')
].join('~');
}
app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks(); const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks(); const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary(); const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary); res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
}); });
app.get('/settings/background-tasks/scheduled', requireScheduledSettingsAccess(), function (req, res) { app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
const tasks = backgroundTaskQueue.listTasks(); const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks(); const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary(); const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary); res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/settings/background-tasks/state', function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (!hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow', 'scheduled-tasks.read', 'scheduled-tasks.allow'])) {
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
return next(error);
}
return next();
}, function (req, res) {
const tasks = backgroundTaskQueue.listTasks();
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
const version = buildQueueVersion(tasks, recurringTasks, summary);
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
res.json({
version: version,
summary: summary,
taskCount: tasks.length,
recurringCount: recurringTasks.length
});
}); });
function requireSettingsManageAccess() { function requireSettingsManageAccess() {
@@ -120,22 +73,7 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
}; };
} }
app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) { function requireRecurringTaskAccess(req, res, next) {
const removedCount = backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
});
app.post('/settings/background-tasks/:id/cancel', requireSettingsManageAccess(), function (req, res) {
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
});
app.post('/settings/background-tasks/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
});
app.post('/settings/background-tasks/recurring/:key/run', function (req, res, next) {
if (!req.currentUser) { if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
} }
@@ -148,8 +86,25 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
} }
return next(); return next();
}, function (req, res) { }
app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), function (req, res) {
const removedCount = backgroundTaskQueue.clearFinishedTasks();
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.'));
});
app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), function (req, res) {
const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.'));
});
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) {
const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.'));
});
app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, function (req, res) {
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim()); const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
res.redirect('/settings/background-tasks/scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.')); res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
}); });
}; };
@@ -10,10 +10,13 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) { app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderCanvasSizesPage({ res.send(pages.renderCanvasSizesPage({
canvasSizes: data.canvasSizes || [], canvasSizes: data.canvasSizes || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+46 -3
View File
@@ -1,22 +1,65 @@
module.exports = function registerClientsRoutes(app, deps) { module.exports = function registerClientsRoutes(app, deps) {
const pool = deps.pool; const pool = deps.pool;
const common = deps.common;
const pages = deps.pages; const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState; const buildDashboardState = deps.buildDashboardState;
const { buildPagination } = require('../../../lib/pagination'); const { buildPagination } = require('../../../lib/pagination');
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
const requirePermission = deps.requirePermission; const requirePermission = deps.requirePermission;
const LIST_PAGE_SIZE = 10; const LIST_PAGE_SIZE = 10;
function sortClients(clients, sortKey, sortDirection) {
const normalizedSortKey = String(sortKey || '').trim();
const normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
const accessors = {
client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); },
screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); },
slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); },
ip: function (client) { return String(client && client.clientIp || '').trim(); },
viewport: function (client) {
const viewport = client && client.viewport;
if (!viewport || !viewport.width || !viewport.height) {
return '';
}
return `${Number(viewport.width) || 0}x${Number(viewport.height) || 0}`;
},
connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); }
};
if (!accessors[normalizedSortKey]) {
return Array.isArray(clients) ? clients.slice() : [];
}
return sortRows(clients, function (client) {
return accessors[normalizedSortKey](client);
}, normalizedDirection);
}
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) { app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
try { try {
const data = await buildDashboardState(pool); const data = await buildDashboardState(pool);
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const totalItems = Array.isArray(data.clients) ? data.clients.length : 0; const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const matchesSearch = createSearchMatcher(search, [
'name',
'client_name',
'clientId',
'deviceId',
'slug',
'screen_slug',
'ipAddress',
'status'
]);
const filteredClients = (data.clients || []).filter(matchesSearch);
const sortedClients = sortClients(filteredClients, sort, direction);
const startIndex = (page - 1) * LIST_PAGE_SIZE; const startIndex = (page - 1) * LIST_PAGE_SIZE;
const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE); const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
res.send(pages.renderConnectedClientsPage({ res.send(pages.renderConnectedClientsPage({
clients: clients, clients: clients,
pagination: buildPagination(totalItems, page, 'page', {}, LIST_PAGE_SIZE, 'clients', 'Client pages') pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+5 -2
View File
@@ -17,6 +17,9 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) { app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
if (req.query.edit) { if (req.query.edit) {
const data = await common.fetchAdminData(pool); const data = await common.fetchAdminData(pool);
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit)); const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
@@ -25,10 +28,10 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
} }
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser)); return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
} }
const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE); const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderPlaylistsPage({ res.send(pages.renderPlaylistsPage({
playlists: data.playlists || [], playlists: data.playlists || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'playlists', 'Playlist pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+37 -4
View File
@@ -15,10 +15,39 @@ module.exports = function registerScreensRoutes(app, deps) {
}; };
} }
function applyConnectionCounts(screens, dashboardScreens) {
const countsById = new Map();
const countsBySlug = new Map();
(dashboardScreens || []).forEach(function (screen) {
const count = Number(screen && screen.player_connection_count || 0);
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
const slug = String(screen && screen.slug || '').trim();
if (id) {
countsById.set(id, count);
}
if (slug) {
countsBySlug.set(slug, count);
}
});
return (screens || []).map(function (screen) {
const slug = String(screen && screen.slug || '').trim();
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
const playerConnectionCount = countsBySlug.has(slug)
? countsBySlug.get(slug)
: countsById.get(id) || 0;
return Object.assign({}, screen, {
player_connection_count: playerConnectionCount
});
});
}
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) { app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
try { try {
if (req.query.edit) { if (req.query.edit) {
const data = await buildDashboardState(pool);
const screen = await common.fetchScreenById(pool, Number(req.query.edit)); const screen = await common.fetchScreenById(pool, Number(req.query.edit));
if (!screen) { if (!screen) {
return res.status(404).send('Screen not found'); return res.status(404).send('Screen not found');
@@ -27,10 +56,14 @@ module.exports = function registerScreensRoutes(app, deps) {
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser)); return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
} }
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const dashboardState = await buildDashboardState(pool);
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderScreensPage({ res.send(pages.renderScreensPage({
screens: data.screens || [], screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []),
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+5 -2
View File
@@ -10,10 +10,13 @@ module.exports = function registerSlidesRoutes(app, deps) {
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderSlidesPage({ res.send(pages.renderSlidesPage({
slides: data.slides || [], slides: data.slides || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+1
View File
@@ -9,6 +9,7 @@ function buildDefaultTemplate() {
canvas_size_height: 1080, canvas_size_height: 1080,
background_color: '#111111', background_color: '#111111',
background_image_path: '', background_image_path: '',
region_usage: [],
regions: [] regions: []
}; };
} }
+3
View File
@@ -6,6 +6,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
name: template.name, name: template.name,
canvas_size_width: template.canvas_size_width, canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height, canvas_size_height: template.canvas_size_height,
slideCount: template.slide_count !== undefined
? Number(template.slide_count) || 0
: 0,
regionCount: template.region_count !== undefined regionCount: template.region_count !== undefined
? Number(template.region_count) || 0 ? Number(template.region_count) || 0
: (data.templateRegions || []).filter((region) => region.template_id === template.id).length : (data.templateRegions || []).filter((region) => region.template_id === template.id).length
+5 -2
View File
@@ -10,10 +10,13 @@ module.exports = function registerTemplatesRoutes(app, deps) {
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) { app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try { try {
const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE); const search = common.getSearchQuery(req);
const sort = common.getSortQuery(req);
const direction = common.getSortDirectionQuery(req);
const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
res.send(pages.renderTemplatesPage({ res.send(pages.renderTemplatesPage({
templates: data.templates || [], templates: data.templates || [],
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages') pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
}, req.query.message ? String(req.query.message) : '', req.currentUser)); }, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) { } catch (error) {
next(error); next(error);
+1 -1
View File
@@ -110,7 +110,7 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
}); });
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8')); Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8')); Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table', 'table-pagination.hbs'), 'utf8'));
function resolveTemplatePath(relativePath) { function resolveTemplatePath(relativePath) {
const firstSegment = String(relativePath || '').split(/[\\/]/)[0]; const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
@@ -22,11 +22,11 @@
<table class="table table-striped w-100 mb-0" data-table-searchable> <table class="table table-striped w-100 mb-0" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-table-sort-key="name">Name</th>
<th>API URL</th> <th data-table-sort-key="url">API URL</th>
<th>Refresh interval</th> <th data-table-sort-key="interval">Refresh interval</th>
<th>Last pulled</th> <th data-table-sort-key="last_pulled">Last pulled</th>
<th>Last response</th> <th data-table-sort-key="last_response">Last response</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -22,10 +22,10 @@
<table class="table table-striped w-100 mb-0" data-table-searchable> <table class="table table-striped w-100 mb-0" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-table-sort-key="name">Name</th>
<th>Feed URL</th> <th data-table-sort-key="url">Feed URL</th>
<th>Refresh interval</th> <th data-table-sort-key="interval">Refresh interval</th>
<th>Items pulled</th> <th data-table-sort-key="items">Items pulled</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
+4 -4
View File
@@ -24,10 +24,10 @@
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable> <table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Role</th> <th data-table-sort-key="role">Role</th>
<th>Description</th> <th data-table-sort-key="description">Description</th>
<th>Users</th> <th data-table-sort-key="users">Users</th>
<th>Permissions</th> <th data-table-sort-key="permissions">Permissions</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -1,5 +1,3 @@
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
<div class="page-header"> <div class="page-header">
<div> <div>
<h2>Background tasks</h2> <h2>Background tasks</h2>
@@ -41,7 +39,7 @@
</div> </div>
{{#if canManage}} {{#if canManage}}
{{#if canClearFinished}} {{#if canClearFinished}}
<form method="post" action="/settings/background-tasks/clear-finished"> <form method="post" action="/settings/tasks-background/clear-finished">
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button> <button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
</form> </form>
{{/if}} {{/if}}
@@ -52,13 +50,13 @@
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable> <table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Task</th> <th data-table-sort-key="task">Task</th>
<th>Category</th> <th data-table-sort-key="category">Category</th>
<th>Status</th> <th data-table-sort-key="status">Status</th>
<th>Queued</th> <th data-table-sort-key="queued">Queued</th>
<th>Started</th> <th data-table-sort-key="started">Started</th>
<th>Finished</th> <th data-table-sort-key="finished">Finished</th>
<th>Source</th> <th data-table-sort-key="source">Source</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -129,27 +127,6 @@
</tbody> </tbody>
</table> </table>
</div> </div>
{{#if pagination.hasMultiplePages}} {{> table-pagination pagination=pagination basePath="/settings/tasks-background"}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="text-muted small">
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
</div>
<nav aria-label="Background task pages">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li>
{{#each pagination.pages}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
</li>
{{/each}}
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
</li>
</ul>
</nav>
</div>
{{/if}}
</div> </div>
</div> </div>
@@ -1,5 +1,3 @@
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
<div class="page-header"> <div class="page-header">
<div> <div>
<h2>Scheduled tasks</h2> <h2>Scheduled tasks</h2>
@@ -21,12 +19,12 @@
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable> <table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Task</th> <th data-table-sort-key="task">Task</th>
<th>Interval</th> <th data-table-sort-key="interval">Interval</th>
<th>Next run</th> <th data-table-sort-key="next_run">Next run</th>
<th>Last run</th> <th data-table-sort-key="last_run">Last run</th>
<th>Last result</th> <th data-table-sort-key="last_result">Last result</th>
<th>Source</th> <th data-table-sort-key="source">Source</th>
{{#if canManage}}<th>Actions</th>{{/if}} {{#if canManage}}<th>Actions</th>{{/if}}
</tr> </tr>
</thead> </thead>
@@ -68,7 +66,7 @@
{{#if ../canManage}} {{#if ../canManage}}
<td data-label="Actions"> <td data-label="Actions">
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
<form method="post" action="/settings/background-tasks/recurring/{{key}}/run"> <form method="post" action="/settings/tasks-scheduled/recurring/{{key}}/run">
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button> <button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
</form> </form>
</div> </div>
+3 -3
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable> <table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>User</th> <th data-table-sort-key="user">User</th>
<th>Name</th> <th data-table-sort-key="name">Name</th>
<th>Roles</th> <th data-table-sort-key="roles">Roles</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
+6 -3
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html data-bs-theme="dark" style="color-scheme: dark;">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
@@ -82,9 +82,12 @@
{{/if}} {{/if}}
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script> <script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
<script src="/assets/adminlte/js/adminlte.min.js"></script> <script src="/assets/adminlte/js/adminlte.min.js"></script>
<script src="/assets/js/admin/toast.js"></script> <script src="/assets/js/web-ui-helpers.js"></script>
<script src="/assets/js/toast.js"></script>
<script src="/assets/js/table/table-sort.js"></script>
<script src="/assets/js/table/table-search.js"></script>
<script src="/assets/js/admin/admin-page.js"></script> <script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/admin/system-status.js"></script> <script src="/assets/js/system-status.js"></script>
{{#if scripts.length}} {{#if scripts.length}}
{{#each scripts}} {{#each scripts}}
<script src="/assets/{{this}}"></script> <script src="/assets/{{this}}"></script>
+11 -9
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html> <html data-bs-theme="dark" style="color-scheme: dark;">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" /> <meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
@@ -17,13 +17,13 @@
{{/each}} {{/each}}
{{/if}} {{/if}}
</head> </head>
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"> <body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"{{#if authShell}} data-bs-theme="dark" style="color-scheme: dark;"{{else if errorShell}} data-bs-theme="dark" style="color-scheme: dark;"{{/if}}>
{{#if errorShell}} {{#if errorShell}}
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}"> <main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
{{{body}}} {{{body}}}
</main> </main>
<script src="/assets/js/admin/admin-page.js"></script> <script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/admin/system-status.js"></script> <script src="/assets/js/system-status.js"></script>
{{else if authShell}} {{else if authShell}}
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3"> <main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
<div class="login-box"> <div class="login-box">
@@ -31,7 +31,7 @@
</div> </div>
</main> </main>
<script src="/assets/js/admin/admin-page.js"></script> <script src="/assets/js/admin/admin-page.js"></script>
<script src="/assets/js/admin/system-status.js"></script> <script src="/assets/js/system-status.js"></script>
{{else}} {{else}}
<div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3"> <div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3">
{{#if message}} {{#if message}}
@@ -243,7 +243,7 @@
{{#if (anyPermission currentUser 'background-tasks.read')}} {{#if (anyPermission currentUser 'background-tasks.read')}}
<ul class="nav nav-treeview"> <ul class="nav nav-treeview">
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks"> <a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
<i class="nav-icon bi bi-list-ol"></i> <i class="nav-icon bi bi-list-ol"></i>
<p>Task queue</p> <p>Task queue</p>
</a> </a>
@@ -251,7 +251,7 @@
{{/if}} {{/if}}
{{#if (hasPermission currentUser 'scheduled-tasks.read')}} {{#if (hasPermission currentUser 'scheduled-tasks.read')}}
<li class="nav-item"> <li class="nav-item">
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/background-tasks/scheduled"> <a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/tasks-scheduled">
<i class="nav-icon bi bi-calendar-event"></i> <i class="nav-icon bi bi-calendar-event"></i>
<p>Scheduled tasks</p> <p>Scheduled tasks</p>
</a> </a>
@@ -284,8 +284,10 @@
</dialog> </dialog>
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script> <script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
<script src="/assets/adminlte/js/adminlte.min.js"></script> <script src="/assets/adminlte/js/adminlte.min.js"></script>
<script src="/assets/js/admin/toast.js"></script> <script src="/assets/js/web-ui-helpers.js"></script>
<script src="/assets/js/table-sort.js"></script> <script src="/assets/js/toast.js"></script>
<script src="/assets/js/table/table-sort.js"></script>
<script src="/assets/js/table/table-search.js"></script>
<script src="/assets/js/admin/admin-page.js"></script> <script src="/assets/js/admin/admin-page.js"></script>
{{#if scripts.length}} {{#if scripts.length}}
{{#each scripts}} {{#each scripts}}
@@ -296,7 +298,7 @@
{{/if}} {{/if}}
{{/each}} {{/each}}
{{/if}} {{/if}}
<script src="/assets/js/admin/system-status.js"></script> <script src="/assets/js/system-status.js"></script>
{{/if}} {{/if}}
</body> </body>
</html> </html>
@@ -1,17 +1,23 @@
{{#if pagination.hasMultiplePages}} {{#if pagination.hasMultiplePages}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3"> <div class="card-footer d-flex flex-wrap align-items-center">
<div class="text-muted small"> <div class="text-muted small">
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}} Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
</div> </div>
<nav aria-label="{{pagination.ariaLabel}}"> <nav class="ms-auto" aria-label="{{pagination.ariaLabel}}">
<ul class="pagination pagination-sm mb-0"> <ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}"> <li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a> <a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li> </li>
{{#each pagination.pages}} {{#each pagination.pages}}
<li class="page-item {{#if active}}active{{/if}}"> {{#if ellipsis}}
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a> <li class="page-item disabled" aria-hidden="true">
</li> <span class="page-link">...</span>
</li>
{{else}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
</li>
{{/if}}
{{/each}} {{/each}}
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}"> <li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a> <a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
@@ -19,4 +25,4 @@
</ul> </ul>
</nav> </nav>
</div> </div>
{{/if}} {{/if}}
+3 -3
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0" data-table-searchable> <table class="table table-striped w-100 mb-0" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-table-sort-key="name">Name</th>
<th>Dimensions</th> <th data-table-sort-key="dimensions">Dimensions</th>
<th>Templates</th> <th data-table-sort-key="templates">Templates</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
+6 -6
View File
@@ -19,12 +19,12 @@
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable> <table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Client</th> <th data-table-sort-key="client">Client</th>
<th>Current Screen</th> <th data-table-sort-key="screen">Current Screen</th>
<th>Current Slide</th> <th data-table-sort-key="slide">Current Slide</th>
<th>IP</th> <th data-table-sort-key="ip">IP</th>
<th>Viewport</th> <th data-table-sort-key="viewport">Viewport</th>
<th>Connected/Updated</th> <th data-table-sort-key="connected">Connected/Updated</th>
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}} {{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
</tr> </tr>
</thead> </thead>
+53 -34
View File
@@ -10,7 +10,7 @@
<div> <div>
<span class="dashboard-hero-kicker">Live overview</span> <span class="dashboard-hero-kicker">Live overview</span>
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3> <h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
<p class="dashboard-hero-copy">Use the cards below for the current totals, then jump to the screens table when you need a deeper look at playback and connection state.</p> <p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen snapshot when you want a quick read on playlist assignment and live connections.</p>
</div> </div>
<div class="dashboard-hero-stats"> <div class="dashboard-hero-stats">
{{#if (hasPermission currentUser "playlists.read")}} {{#if (hasPermission currentUser "playlists.read")}}
@@ -63,6 +63,22 @@
><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button> ><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button>
<span class="dashboard-action-help">Restart every connected player page.</span> <span class="dashboard-action-help">Restart every connected player page.</span>
</form> </form>
<form
method="post"
action="/commands"
class="dashboard-action-form"
data-confirm-message="Pause all connected clients?"
data-async-command
>
<input type="hidden" name="command" value="pause" />
<input type="hidden" name="paused" value="true" />
<button
type="submit"
class="btn btn-info dashboard-action-button"
id="dashboard-pause-all-button"
><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause all clients</button>
<span class="dashboard-action-help">Freeze playback on every connected player.</span>
</form>
<form <form
method="post" method="post"
action="/commands" action="/commands"
@@ -91,46 +107,49 @@
{{#if (hasPermission currentUser "screens.read")}} {{#if (hasPermission currentUser "screens.read")}}
<div class="row"> <div class="row">
<div class="col-12"> <div class="col-12">
<div class="card card-outline card-secondary dashboard-table-card"> <div class="card card-outline card-secondary dashboard-screen-card-shell">
<div class="card-header"> <div class="card-header dashboard-screen-card-header">
<div class="dashboard-card-heading"> <div class="dashboard-card-heading">
<h3 class="card-title">Current screens</h3> <h3 class="card-title">Screen snapshot</h3>
<p class="dashboard-card-subtitle">Player URL, playlist assignment, <p class="dashboard-card-subtitle">Player URL, playlist assignment,
and live connection count.</p> and live connection count without the spreadsheet feel.</p>
</div> </div>
<a class="btn btn-sm btn-outline-primary" href="/screens">Manage screens</a>
</div> </div>
<div class="card-body table-responsive p-0"> <div class="card-body">
<table <div id="dashboard-screens-grid" class="dashboard-screen-grid">
id="dashboard-screens-table" {{#if screens.length}}
class="table table-striped w-100 mb-0" {{#each screens}}
> <article class="dashboard-screen-tile" data-screen-key="{{id}}">
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th <div class="dashboard-screen-tile-top">
>Connected clients</th></tr></thead> <div class="dashboard-screen-tile-text">
<tbody> <h4 class="dashboard-screen-name">{{name}}</h4>
{{#if screens.length}} <a class="dashboard-screen-link" href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a>
{{#each screens}} </div>
<tr data-client-key="{{id}}"> <span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
<td data-label="Name">{{name}}</td>
<td data-label="Player URL"><a
href="{{playerUrl slug}}"
target="_blank"
>{{playerUrl slug}}</a></td>
<td data-label="Playlist">{{playlist_name}}</td>
<td data-label="Connected clients">
{{#if player_connection_count}} {{#if player_connection_count}}
<div class="connection-count">{{player_connection_count}} {{player_connection_count}} live
connected</div>
{{else}} {{else}}
<span class="empty">No clients connected.</span> No clients
{{/if}} {{/if}}
</td> </span>
</tr> </div>
{{/each}} <dl class="dashboard-screen-meta">
{{else}} <div>
<tr><td colspan="4" class="empty">No screens yet.</td></tr> <dt>Playlist</dt>
{{/if}} <dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
</tbody> </div>
</table> <div>
<dt>Connections</dt>
<dd>{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}</dd>
</div>
</dl>
</article>
{{/each}}
{{else}}
<div class="empty dashboard-screen-empty">No screens yet.</div>
{{/if}}
</div>
</div> </div>
</div> </div>
</div> </div>
+2 -2
View File
@@ -22,8 +22,8 @@
<table class="table table-striped w-100 mb-0" data-table-searchable> <table class="table table-striped w-100 mb-0" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-table-sort-key="name">Name</th>
<th>Slides</th> <th data-table-sort-key="slides">Slides</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
+3 -3
View File
@@ -22,9 +22,9 @@
<table class="table table-striped w-100 mb-0" data-table-searchable> <table class="table table-striped w-100 mb-0" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-table-sort-key="name">Name</th>
<th>Player URL</th> <th data-table-sort-key="url">Player URL</th>
<th>Playlist</th> <th data-table-sort-key="playlist">Playlist</th>
<th>Connected clients</th> <th>Connected clients</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
+2 -2
View File
@@ -23,8 +23,8 @@
<thead> <thead>
<tr> <tr>
<th data-sortable="false">Thumbnail</th> <th data-sortable="false">Thumbnail</th>
<th>Title</th> <th data-table-sort-key="title">Title</th>
<th>Template</th> <th data-table-sort-key="template">Template</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
+8 -7
View File
@@ -5,13 +5,14 @@
</div> </div>
</div> </div>
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form"> <form method="post" action="/templates" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}">
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" /> <input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" /> <input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" /> <input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
<input type="hidden" id="canvas-size-summary" /> <input type="hidden" id="canvas-size-summary" />
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" /> <input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
<input type="hidden" name="regions_json" id="regions-json" value="" /> <input type="hidden" name="regions_json" id="regions-json" value="" />
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
<div class="template-designer-layout"> <div class="template-designer-layout">
<div class="card card-outline card-secondary admin-form-card mb-3"> <div class="card card-outline card-secondary admin-form-card mb-3">
@@ -30,7 +31,7 @@
</div> </div>
<div class="template-designer-sidebar"> <div class="template-designer-sidebar">
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-body d-grid gap-2"> <div class="card-body d-grid">
<div class="btn-group template-actions-group" role="group" aria-label="Template actions"> <div class="btn-group template-actions-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}} {{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}}
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a> <a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
@@ -114,20 +115,20 @@
<div class="modal-body"> <div class="modal-body">
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p> <p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
</div> </div>
</div> </div>
{{/modal-shell}} {{/modal-shell}}
<template id="region-card-template"> <template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item mb-3"> <div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div> <div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3"> <div class="card-body p-3 d-grid gap-3">
<div class="region-field-grid region-field-grid--identity"> <div class="region-field-grid region-field-grid--identity">
@@ -146,8 +147,8 @@
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label> <label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label> <label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label> <label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button>Remove</button></label>
</div> </div>
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
</div> </div>
</div> </div>
</template> </template>
+8 -7
View File
@@ -5,13 +5,14 @@
</div> </div>
</div> </div>
<form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new"> <form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new">
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" /> <input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" /> <input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" /> <input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
<input type="hidden" id="canvas-size-summary" /> <input type="hidden" id="canvas-size-summary" />
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" /> <input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
<input type="hidden" name="regions_json" id="regions-json" value="" /> <input type="hidden" name="regions_json" id="regions-json" value="" />
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
<div class="template-designer-layout"> <div class="template-designer-layout">
<div class="card card-outline card-secondary admin-form-card mb-3"> <div class="card card-outline card-secondary admin-form-card mb-3">
@@ -30,7 +31,7 @@
</div> </div>
<div class="template-designer-sidebar"> <div class="template-designer-sidebar">
<div class="card card-outline card-primary admin-form-card"> <div class="card card-outline card-primary admin-form-card">
<div class="card-body d-grid gap-2"> <div class="card-body d-grid">
<div class="btn-group template-actions-group" role="group" aria-label="Template actions"> <div class="btn-group template-actions-group" role="group" aria-label="Template actions">
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}} {{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a> <a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
@@ -121,20 +122,20 @@
<div class="modal-body"> <div class="modal-body">
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p> <p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
<div class="d-grid gap-2"> <div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button> <button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
</div> </div>
</div> </div>
{{/modal-shell}} {{/modal-shell}}
<template id="region-card-template"> <template id="region-card-template">
<div class="card card-outline card-secondary admin-form-card region-item mb-3"> <div class="card card-outline card-secondary admin-form-card region-item">
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div> <div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
<div class="card-body p-3 d-grid gap-3"> <div class="card-body p-3 d-grid gap-3">
<div class="region-field-grid region-field-grid--identity"> <div class="region-field-grid region-field-grid--identity">
@@ -153,8 +154,8 @@
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label> <label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label> <label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label> <label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button disabled>Remove</button></label>
</div> </div>
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
</div> </div>
</div> </div>
</template> </template>
+6 -4
View File
@@ -22,9 +22,10 @@
<table class="table table-striped w-100 mb-0" data-table-searchable> <table class="table table-striped w-100 mb-0" data-table-searchable>
<thead> <thead>
<tr> <tr>
<th>Name</th> <th data-table-sort-key="name">Name</th>
<th>Canvas</th> <th data-table-sort-key="canvas">Canvas</th>
<th>Regions</th> <th data-table-sort-key="regions">Regions</th>
<th data-table-sort-key="slides">Slides used by</th>
<th>Actions</th> <th>Actions</th>
</tr> </tr>
</thead> </thead>
@@ -35,6 +36,7 @@
<td data-label="Name">{{name}}</td> <td data-label="Name">{{name}}</td>
<td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td> <td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td>
<td data-label="Regions">{{regionCount}}</td> <td data-label="Regions">{{regionCount}}</td>
<td data-label="Slides used by">{{slideCount}}</td>
<td data-label="Actions"> <td data-label="Actions">
{{#if (anyPermission ../currentUser 'templates.update' 'templates.delete')}} {{#if (anyPermission ../currentUser 'templates.update' 'templates.delete')}}
<div class="actions"> <div class="actions">
@@ -54,7 +56,7 @@
</tr> </tr>
{{/each}} {{/each}}
{{else}} {{else}}
<tr data-table-search-empty-default><td colspan="4" class="empty">No templates yet.</td></tr> <tr data-table-search-empty-default><td colspan="5" class="empty">No templates yet.</td></tr>
{{/if}} {{/if}}
</tbody> </tbody>
</table> </table>