diff --git a/CHANGELOG.md b/CHANGELOG.md index c97073b..53bbb88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,18 @@ All notable changes to this project will be documented in this file. - No unreleased changes recorded yet. +## 1.5.13 - 2026-07-26 + +### Added + +- Shared table helpers for pagination, search, and sorting across the admin and signage views. + +### Changed + +- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules. +- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow. +- Updated the admin and player branding assets and tightened the page scripts around the new layout structure. + ## 1.5.12 - 2026-07-26 ### Changed @@ -58,13 +70,6 @@ All notable changes to this project will be documented in this file. ### Fixed -- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it. -- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly. - -## 1.5.7 - 2026-07-25 - -### Fixed - - Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images. ## 1.5.6 - 2026-07-25 diff --git a/package-lock.json b/package-lock.json index b40b4ae..0e2b8b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "1.5.12", + "version": "1.5.13", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "1.5.12", + "version": "1.5.13", "dependencies": { "@sparticuz/chromium": "^137.0.0", "bootstrap-icons": "1.11.3", diff --git a/package.json b/package.json index ab90940..42427fc 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "1.5.12", + "version": "1.5.13", "private": false, "description": "Pulse Signage application with MySQL and media storage", "repository": { diff --git a/src/common.js b/src/common.js index 8b4fbf7..4e6af73 100644 --- a/src/common.js +++ b/src/common.js @@ -2,6 +2,18 @@ const db = require('./db'); const data = require('./data'); const player = require('./player/render'); +function getSearchQuery(req) { + return String(req && req.query && req.query.search || '').trim(); +} + +function getSortQuery(req) { + return String(req && req.query && req.query.sort || '').trim(); +} + +function getSortDirectionQuery(req) { + return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; +} + module.exports = { createPool: db.createPool, ensureSchema: db.ensureSchema, @@ -14,6 +26,9 @@ module.exports = { fetchTemplatesPage: data.fetchTemplatesPage, fetchCanvasSizesPage: data.fetchCanvasSizesPage, fetchScreensPage: data.fetchScreensPage, + getSearchQuery: getSearchQuery, + getSortQuery: getSortQuery, + getSortDirectionQuery: getSortDirectionQuery, fetchPlaylistById: data.fetchPlaylistById, fetchApiSourcesData: data.fetchApiSourcesData, fetchApiSourcesPage: data.fetchApiSourcesPage, diff --git a/src/data/admin.js b/src/data/admin.js index 35e895a..440f3fb 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -35,13 +35,23 @@ async function fetchAdminData(pool) { return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides }; } -async function fetchPlaylistsPage(pool, page, pageSize) { +async function fetchPlaylistsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT p.id, p.name, p.fade_between_slides, p.skip_unavailable_rtmp, p.created_at, p.modified_at, p.created_by, p.modified_by, (SELECT COUNT(*) FROM playlist_slides ps WHERE ps.playlist_id = p.id) AS slide_count FROM playlists p ORDER BY p.id DESC`, countSql: 'SELECT COUNT(*) AS count FROM playlists', + searchColumns: ['p.name'], + searchTerm: searchTerm, + sortColumns: { + name: 'p.name', + slides: 'slide_count', + created: 'p.created_at', + modified: 'p.modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); @@ -49,7 +59,7 @@ async function fetchPlaylistsPage(pool, page, pageSize) { return Object.assign({ playlists: paged.rows }, paged); } -async function fetchSlidesPage(pool, page, pageSize) { +async function fetchSlidesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height FROM slides s @@ -57,6 +67,16 @@ async function fetchSlidesPage(pool, page, pageSize) { LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY s.id DESC`, countSql: 'SELECT COUNT(*) AS count FROM slides', + searchColumns: ['s.title', 's.body', 's.media_path', 'st.name', 's.content_json'], + searchTerm: searchTerm, + sortColumns: { + title: 's.title', + template: 'st.name', + created: 's.created_at', + modified: 's.modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); @@ -64,7 +84,7 @@ async function fetchSlidesPage(pool, page, pageSize) { return Object.assign({ slides: paged.rows }, paged); } -async function fetchTemplatesPage(pool, page, pageSize) { +async function fetchTemplatesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, @@ -74,6 +94,18 @@ async function fetchTemplatesPage(pool, page, pageSize) { LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC`, countSql: 'SELECT COUNT(*) AS count FROM slide_templates', + searchColumns: ['st.name', 'st.background_image_path', 'st.background_color', 'cs.name'], + searchTerm: searchTerm, + sortColumns: { + name: 'st.name', + canvas: ['cs.width', 'cs.height', 'cs.name'], + regions: 'region_count', + slides: 'slide_count', + created: 'st.created_at', + modified: 'st.modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); @@ -81,13 +113,24 @@ async function fetchTemplatesPage(pool, page, pageSize) { return Object.assign({ templates: paged.rows }, paged); } -async function fetchCanvasSizesPage(pool, page, pageSize) { +async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT id, name, width, height, created_at, modified_at, created_by, modified_by, (SELECT COUNT(*) FROM slide_templates st WHERE st.canvas_size_id = canvas_sizes.id) AS template_count FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC`, countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', + searchColumns: ['name', 'width', 'height'], + searchTerm: searchTerm, + sortColumns: { + name: 'name', + dimensions: ['width', 'height', 'name'], + templates: 'template_count', + created: 'created_at', + modified: 'modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); @@ -95,13 +138,24 @@ async function fetchCanvasSizesPage(pool, page, pageSize) { return Object.assign({ canvasSizes: paged.rows }, paged); } -async function fetchScreensPage(pool, page, pageSize) { +async function fetchScreensPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT s.id, s.name, s.slug, s.playlist_id, s.created_at, s.modified_at, s.created_by, s.modified_by, p.name AS playlist_name FROM screens s LEFT JOIN playlists p ON p.id = s.playlist_id ORDER BY s.id DESC`, countSql: 'SELECT COUNT(*) AS count FROM screens', + searchColumns: ['s.name', 's.slug', 'p.name'], + searchTerm: searchTerm, + sortColumns: { + name: 's.name', + url: 's.slug', + playlist: 'p.name', + created: 's.created_at', + modified: 's.modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); diff --git a/src/data/api-sources.js b/src/data/api-sources.js index af6623e..6e7ea65 100644 --- a/src/data/api-sources.js +++ b/src/data/api-sources.js @@ -15,10 +15,23 @@ async function fetchApiSourcesData(pool) { return { apiSources: apiSources }; } -async function fetchApiSourcesPage(pool, page, pageSize) { +async function fetchApiSourcesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: 'SELECT id, name, api_url, update_interval_value, update_interval_unit, last_pulled_at, last_pull_error, last_response_status, last_response_content_type, last_response_json, created_at, modified_at, created_by, modified_by FROM api_sources ORDER BY modified_at DESC, id DESC', countSql: 'SELECT COUNT(*) AS count FROM api_sources', + searchColumns: ['name', 'api_url', 'last_pull_error'], + searchTerm: searchTerm, + sortColumns: { + name: 'name', + url: 'api_url', + interval: ['update_interval_value', 'update_interval_unit'], + last_pulled: 'last_pulled_at', + last_response: 'last_response_status', + created: 'created_at', + modified: 'modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); diff --git a/src/data/canvas-sizes.js b/src/data/canvas-sizes.js index 1173686..6d0ee4a 100644 --- a/src/data/canvas-sizes.js +++ b/src/data/canvas-sizes.js @@ -5,10 +5,20 @@ async function fetchCanvasSizesData(pool) { return { canvasSizes }; } -async function fetchCanvasSizesPage(pool, page, pageSize) { +async function fetchCanvasSizesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: 'SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC', countSql: 'SELECT COUNT(*) AS count FROM canvas_sizes', + searchColumns: ['name', 'width', 'height'], + searchTerm: searchTerm, + sortColumns: { + name: 'name', + dimensions: ['width', 'height', 'name'], + created: 'created_at', + modified: 'modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); diff --git a/src/data/rss-feeds.js b/src/data/rss-feeds.js index 123f50d..5a24df2 100644 --- a/src/data/rss-feeds.js +++ b/src/data/rss-feeds.js @@ -15,10 +15,22 @@ async function fetchRssFeedsData(pool) { return { rssFeeds: rssFeeds }; } -async function fetchRssFeedsPage(pool, page, pageSize) { +async function fetchRssFeedsPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: 'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds ORDER BY modified_at DESC, id DESC', countSql: 'SELECT COUNT(*) AS count FROM rss_feeds', + searchColumns: ['name', 'feed_url'], + searchTerm: searchTerm, + sortColumns: { + name: 'name', + url: 'feed_url', + interval: ['update_interval_value', 'update_interval_unit'], + items: 'item_limit', + created: 'created_at', + modified: 'modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); diff --git a/src/data/utils.js b/src/data/utils.js index 0fd341c..056265f 100644 --- a/src/data/utils.js +++ b/src/data/utils.js @@ -27,23 +27,168 @@ function normalizePageNumber(value) { return Math.max(1, pageNumber); } +function escapeLikeValue(value) { + return String(value || '').replace(/[\\%_]/g, '\\$&'); +} + +function normalizeSortDirection(value) { + return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; +} + +function buildSortOrderClause(sortColumns, sortKey, sortDirection) { + const normalizedSortKey = String(sortKey || '').trim(); + const normalizedSortDirection = normalizeSortDirection(sortDirection); + const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {}; + const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null; + + if (!sortExpression) { + return { + clause: '', + sortKey: normalizedSortKey, + sortDirection: normalizedSortDirection + }; + } + + const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression]; + const orderBySql = expressions.map(function (expression) { + return `${expression} ${normalizedSortDirection.toUpperCase()}`; + }).join(', '); + + return { + clause: ` ORDER BY ${orderBySql}`, + sortKey: normalizedSortKey, + sortDirection: normalizedSortDirection + }; +} + +function findTopLevelOrderByIndex(sql) { + const text = String(sql || ''); + let depth = 0; + let inSingleQuote = false; + let inDoubleQuote = false; + let inBacktick = false; + let lastOrderByIndex = -1; + + for (let index = 0; index < text.length; index += 1) { + const character = text[index]; + const previousCharacter = index > 0 ? text[index - 1] : ''; + + if (inSingleQuote) { + if (character === '\'' && previousCharacter !== '\\') { + inSingleQuote = false; + } + continue; + } + + if (inDoubleQuote) { + if (character === '"' && previousCharacter !== '\\') { + inDoubleQuote = false; + } + continue; + } + + if (inBacktick) { + if (character === '`') { + inBacktick = false; + } + continue; + } + + if (character === '\'') { + inSingleQuote = true; + continue; + } + + if (character === '"') { + inDoubleQuote = true; + continue; + } + + if (character === '`') { + inBacktick = true; + continue; + } + + if (character === '(') { + depth += 1; + continue; + } + + if (character === ')' && depth > 0) { + depth -= 1; + continue; + } + + if (depth === 0 && /[oO]/.test(character)) { + const remaining = text.slice(index); + if (/^order\s+by\b/i.test(remaining)) { + lastOrderByIndex = index; + } + } + } + + return lastOrderByIndex; +} + +function buildSearchFilter(searchColumns, searchTerm) { + const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) { + return String(column || '').trim(); + }).filter(Boolean) : []; + const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase(); + + if (!columns.length || !normalizedSearchTerm) { + return { + clause: '', + params: [] + }; + } + + const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`; + return { + clause: ` WHERE (${columns.map(function (column) { + return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`; + }).join(' OR ')})`, + params: columns.map(function () { + return likeValue; + }) + }; +} + async function fetchPagedRows(pool, options) { const selectSql = String(options && options.selectSql || '').trim(); const countSql = String(options && options.countSql || '').trim(); const params = Array.isArray(options && options.params) ? options.params : []; + const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm); + const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection); const pageSize = Math.max(1, Number(options && options.pageSize) || 10); const currentPage = normalizePageNumber(options && options.page); if (!selectSql || !countSql) { throw new Error('fetchPagedRows requires selectSql and countSql.'); } + const orderByIndex = findTopLevelOrderByIndex(selectSql); + let baseSelectSql = selectSql; + let orderBySql = ''; - const [countRows] = await pool.query(countSql, params); + if (orderByIndex >= 0) { + baseSelectSql = selectSql.slice(0, orderByIndex).trim(); + orderBySql = selectSql.slice(orderByIndex).trim(); + } + + const filteredSelectSql = `${baseSelectSql}${searchFilter.clause}`; + const countQuery = searchFilter.clause + ? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows` + : countSql; + const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params; + const [countRows] = await pool.query(countQuery, countParams); const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0; const totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); const safeCurrentPage = Math.min(currentPage, totalPages); const offset = (safeCurrentPage - 1) * pageSize; - const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset])); + + const activeOrderBySql = sortOrder.clause || (orderBySql ? ` ${orderBySql}` : ''); + const selectQuery = `${filteredSelectSql}${activeOrderBySql} LIMIT ? OFFSET ?`; + const [rows] = await pool.query(selectQuery, params.concat(searchFilter.params, [pageSize, offset])); return { rows: rows || [], @@ -77,6 +222,10 @@ module.exports = { parseJsonSafe, readFormArray, normalizePageNumber, + normalizeSortDirection, + buildSortOrderClause, + findTopLevelOrderByIndex, + buildSearchFilter, fetchPagedRows, fetchDuplicateName }; diff --git a/src/player/public/favicon.png b/src/player/public/favicon.png index 39d265f..bfc6249 100644 Binary files a/src/player/public/favicon.png and b/src/player/public/favicon.png differ diff --git a/src/player/public/js/player-page-commands.js b/src/player/public/js/player-page-commands.js index 8745a93..cb7c03d 100644 --- a/src/player/public/js/player-page-commands.js +++ b/src/player/public/js/player-page-commands.js @@ -320,7 +320,12 @@ function handleCommandMessage(rawMessage) { } return; case 'pause': - setPaused(!isPaused); + var desiredPause = normalizeBoolean(payload.paused); + if (desiredPause !== null) { + setPaused(desiredPause); + } else { + setPaused(!isPaused); + } return; case 'blackout': var desiredBlackout = normalizeBoolean(payload.blackout); diff --git a/src/web/lib/dashboard-state.js b/src/web/lib/dashboard-state.js index f4fcc7b..0202f9d 100644 --- a/src/web/lib/dashboard-state.js +++ b/src/web/lib/dashboard-state.js @@ -33,6 +33,18 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa }); } +function compareScreenNames(left, right) { + const leftName = String(left && left.name || '').trim(); + const rightName = String(right && right.name || '').trim(); + const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true }); + + if (nameCompare !== 0) { + return nameCompare; + } + + return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true }); +} + function createDashboardStateService(options) { const pool = options && options.pool; const common = options && options.common; @@ -82,11 +94,13 @@ function createDashboardStateService(options) { } }); - const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) { - return Object.assign({}, screen, { - player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` - }); - }); + const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug) + .map(function (screen) { + return Object.assign({}, screen, { + player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}` + }); + }) + .sort(compareScreenNames); const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate); const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) { return socket && socket.readyState === WebSocket.OPEN; diff --git a/src/web/lib/list-query.js b/src/web/lib/list-query.js new file mode 100644 index 0000000..b639c39 --- /dev/null +++ b/src/web/lib/list-query.js @@ -0,0 +1,89 @@ +function parsePageNumber(value) { + const pageNumber = Math.floor(Number(value) || 1); + return Math.max(1, pageNumber); +} + +function normalizeSortDirection(value) { + return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; +} + +function getComparableSortValue(rawValue) { + const value = String(rawValue || '').trim(); + if (!value) { + return { type: 'empty', value: '' }; + } + + const numericValue = Number(value.replace(/,/g, '')); + if (!Number.isNaN(numericValue)) { + return { type: 'number', value: numericValue }; + } + + const dateValue = Date.parse(value); + if (!Number.isNaN(dateValue)) { + return { type: 'date', value: dateValue }; + } + + return { type: 'string', value: value.toLowerCase() }; +} + +function compareSortValues(leftValue, rightValue) { + if (leftValue.type === 'empty' && rightValue.type === 'empty') { + return 0; + } + if (leftValue.type === 'empty') { + return 1; + } + if (rightValue.type === 'empty') { + return -1; + } + if (leftValue.type === rightValue.type) { + if (leftValue.value < rightValue.value) { + return -1; + } + if (leftValue.value > rightValue.value) { + return 1; + } + return 0; + } + return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' }); +} + +function sortRows(rows, accessor, sortDirection) { + const multiplier = normalizeSortDirection(sortDirection) === 'desc' ? -1 : 1; + return (Array.isArray(rows) ? rows.slice() : []).sort(function (leftRow, rightRow) { + return compareSortValues(getComparableSortValue(accessor(leftRow)), getComparableSortValue(accessor(rightRow))) * multiplier; + }); +} + +function createSearchMatcher(searchTerm, fields) { + const query = String(searchTerm || '').trim().toLowerCase(); + + if (!query) { + return function () { + return true; + }; + } + + const normalizedFields = Array.isArray(fields) ? fields : []; + return function (item) { + const haystack = normalizedFields.map(function (field) { + if (typeof field === 'function') { + return field(item); + } + return item && field ? item[field] : ''; + }).map(function (value) { + return String(value || '').trim().toLowerCase(); + }).join(' '); + + return haystack.indexOf(query) !== -1; + }; +} + +module.exports = { + parsePageNumber: parsePageNumber, + normalizeSortDirection: normalizeSortDirection, + getComparableSortValue: getComparableSortValue, + compareSortValues: compareSortValues, + sortRows: sortRows, + createSearchMatcher: createSearchMatcher +}; \ No newline at end of file diff --git a/src/web/lib/pagination.js b/src/web/lib/pagination.js index 3473701..49033e9 100644 --- a/src/web/lib/pagination.js +++ b/src/web/lib/pagination.js @@ -17,26 +17,61 @@ function buildQueryString(query) { return queryString ? `?${queryString}` : ''; } +function createPageEntry(pageNumber, currentPage, pageParam, queryState) { + const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber }); + + return { + number: pageNumber, + active: pageNumber === currentPage, + url: buildQueryString(nextQuery) + }; +} + +function createEllipsisEntry() { + return { + ellipsis: true + }; +} + function buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) { const pages = []; const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages)); - let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2)); - let endPage = startPage + visiblePageCount - 1; + const firstPage = 1; + const lastPage = totalPages; - if (endPage > totalPages) { - endPage = totalPages; - startPage = Math.max(1, endPage - visiblePageCount + 1); + if (totalPages <= visiblePageCount) { + for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) { + pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState)); + } + + return pages; + } + + const innerPageCount = Math.max(1, visiblePageCount - 2); + let startPage = Math.max(2, currentPage - Math.floor(innerPageCount / 2)); + let endPage = startPage + innerPageCount - 1; + + if (endPage > lastPage - 1) { + endPage = lastPage - 1; + startPage = Math.max(2, endPage - innerPageCount + 1); + } + + pages.push(createPageEntry(firstPage, currentPage, pageParam, queryState)); + + if (startPage > 2) { + pages.push(createEllipsisEntry()); } for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) { - const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber }); - pages.push({ - number: pageNumber, - active: pageNumber === currentPage, - url: buildQueryString(nextQuery) - }); + pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState)); } + if (endPage < lastPage - 1) { + pages.push(createEllipsisEntry()); + } + + pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState)); + return pages; } @@ -51,6 +86,8 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 }); const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 }); + const firstQuery = Object.assign({}, queryState, { [normalizedPageParam]: 1 }); + const lastQuery = Object.assign({}, queryState, { [normalizedPageParam]: totalPages }); return { currentPage: safeCurrentPage, @@ -59,8 +96,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz hasMultiplePages: totalPages > 1, startItem: startIndex + 1, endItem: endIndex, + hasFirst: safeCurrentPage > 1, hasPrevious: safeCurrentPage > 1, hasNext: safeCurrentPage < totalPages, + hasLast: safeCurrentPage < totalPages, + firstUrl: buildQueryString(firstQuery), + lastUrl: buildQueryString(lastQuery), previousUrl: buildQueryString(previousQuery), nextUrl: buildQueryString(nextQuery), pages: pages, diff --git a/src/web/lib/rbac-data.js b/src/web/lib/rbac-data.js index e06c73a..5528aa3 100644 --- a/src/web/lib/rbac-data.js +++ b/src/web/lib/rbac-data.js @@ -28,7 +28,7 @@ async function fetchRoles(pool) { return rows || []; } -async function fetchRolesPage(pool, page, pageSize) { +async function fetchRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at, (SELECT COUNT(*) FROM user_roles ur WHERE ur.role_id = r.id) AS user_count, @@ -36,6 +36,18 @@ async function fetchRolesPage(pool, page, pageSize) { FROM roles r ORDER BY r.name ASC`, countSql: 'SELECT COUNT(*) AS count FROM roles', + searchColumns: ['r.role_key', 'r.name', 'r.description'], + searchTerm: searchTerm, + sortColumns: { + role: 'r.name', + description: 'r.description', + users: 'user_count', + permissions: 'permission_count', + created: 'r.created_at', + modified: 'r.modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); @@ -122,7 +134,7 @@ async function fetchUsersWithRoles(pool) { }); } -async function fetchUsersWithRolesPage(pool, page, pageSize) { +async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) { const paged = await fetchPagedRows(pool, { selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at, COALESCE(role_data.role_names, '') AS role_names, @@ -138,6 +150,17 @@ async function fetchUsersWithRolesPage(pool, page, pageSize) { ) role_data ON role_data.user_id = u.id ORDER BY u.id ASC`, countSql: 'SELECT COUNT(*) AS count FROM users', + searchColumns: ['u.name', 'u.username', 'role_data.role_names'], + searchTerm: searchTerm, + sortColumns: { + user: 'u.username', + name: 'u.name', + roles: 'role_names', + created: 'u.created_at', + modified: 'u.modified_at' + }, + sortKey: sortKey, + sortDirection: sortDirection, page: page, pageSize: pageSize }); diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index acd6867..0653930 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -267,7 +267,7 @@ } .dashboard-actions-card .card-header, -.dashboard-table-card .card-header { +.dashboard-screen-card-shell .card-header { padding-top: 1rem; padding-bottom: 1rem; } @@ -309,6 +309,107 @@ font-size: 0.875rem; } +.dashboard-screen-card-header { + display: flex; + align-items: flex-start; + justify-content: space-between; +} + +.dashboard-screen-card-header .dashboard-card-heading { + flex: 1 1 auto; + min-width: 0; +} + +.dashboard-screen-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr)); + gap: 1rem; +} + +.dashboard-screen-tile { + display: grid; + gap: 0.95rem; + padding: 1rem; + border: 1px solid var(--bs-border-color); + border-radius: calc(var(--bs-border-radius) + 0.25rem); + background: var(--bs-body-bg); +} + +.dashboard-screen-tile-top { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 0.75rem; +} + +.dashboard-screen-tile-text { + min-width: 0; +} + +.dashboard-screen-name { + margin: 0; + font-size: 1rem; + line-height: 1.2; +} + +.dashboard-screen-link { + display: inline-block; + margin-top: 0.25rem; + color: var(--bs-secondary-color); + font-size: 0.875rem; + overflow-wrap: anywhere; +} + +.dashboard-screen-pill { + display: inline-flex; + align-items: center; + flex: 0 0 auto; + padding: 0.3rem 0.6rem; + border-radius: 999px; + font-size: 0.75rem; + font-weight: 700; + white-space: nowrap; +} + +.dashboard-screen-pill.is-live { + background: rgba(25, 135, 84, 0.12); + color: var(--bs-success); +} + +.dashboard-screen-pill.is-idle { + background: rgba(108, 117, 125, 0.12); + color: var(--bs-secondary-color); +} + +.dashboard-screen-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 0.75rem; + margin: 0; +} + +.dashboard-screen-meta div { + min-width: 0; +} + +.dashboard-screen-meta dt { + color: var(--bs-secondary-color); + font-size: 0.75rem; + font-weight: 700; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.dashboard-screen-meta dd { + margin: 0.15rem 0 0; + overflow-wrap: anywhere; +} + +.dashboard-screen-empty { + margin: 0; + padding: 0.25rem 0; +} + .page-header { display: flex; align-items: flex-end; diff --git a/src/web/public/favicon.png b/src/web/public/favicon.png index 39d265f..bfc6249 100644 Binary files a/src/web/public/favicon.png and b/src/web/public/favicon.png differ diff --git a/src/web/public/js/admin/admin-page.js b/src/web/public/js/admin/admin-page.js index 998c0f3..61d80c0 100644 --- a/src/web/public/js/admin/admin-page.js +++ b/src/web/public/js/admin/admin-page.js @@ -164,9 +164,6 @@ if (typeof window.initTableSearches === 'function') { window.initTableSearches(targetElement); } - if (typeof window.initTablePaginations === 'function') { - window.initTablePaginations(targetElement); - } } function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) { @@ -201,7 +198,7 @@ currentTarget.outerHTML = nextTarget.outerHTML; rebindRefreshTarget(document.querySelector(refreshTargetSelector)); - }).catch(function (_error) { + }).catch(function () { // Ignore refresh replacement failures and leave the existing content in place. }); } @@ -515,303 +512,10 @@ }); } - function initTablePaginations(root) { - var scope = root && root.querySelectorAll ? root : document; - - Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) { - if (container.getAttribute('data-table-pagination-bound') === 'true') { - return; - } - - var table = container.querySelector('[data-table-searchable]'); - var cardHeader = container.querySelector('.card-header'); - var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body'); - var footer = container.querySelector('[data-table-pagination-controls]'); - var footerSummary = null; - var footerList = null; - var syncFrame = 0; - var state = { - currentPage: 1, - pageSize: 1, - totalPages: 1 - }; - - if (!table || !cardBody || !table.tBodies.length) { - return; - } - - container.setAttribute('data-table-pagination-bound', 'true'); - - function getRows() { - return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]')); - } - - function isSearchMatched(row) { - return String(row.getAttribute('data-table-search-match') || 'true') !== 'false'; - } - - function ensureFooter() { - if (footer) { - footerSummary = footer.querySelector('[data-table-pagination-summary]'); - footerList = footer.querySelector('[data-table-pagination-list]'); - return footer; - } - - footer = document.createElement('div'); - footer.className = 'card-footer d-none'; - footer.setAttribute('data-table-pagination-controls', 'true'); - footer.innerHTML = '' + - '
' + - '
' + - '' + - '
'; - cardBody.parentNode.insertBefore(footer, cardBody.nextSibling); - footerSummary = footer.querySelector('[data-table-pagination-summary]'); - footerList = footer.querySelector('[data-table-pagination-list]'); - return footer; - } - - function setRowVisible(row, visible) { - row.hidden = !visible; - row.classList.toggle('d-none', !visible); - } - - function getAvailableHeight() { - var containerRect = container.getBoundingClientRect(); - var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0; - var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56; - return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24); - } - - function renderFooter(totalItems) { - var pageButtons = []; - - ensureFooter(); - - if (state.totalPages <= 1 || totalItems <= 0) { - footer.classList.add('d-none'); - footerSummary.textContent = ''; - footerList.innerHTML = ''; - return; - } - - footer.classList.remove('d-none'); - footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems; - - pageButtons.push('
  • Previous
  • '); - for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) { - pageButtons.push('
  • ' + pageNumber + '
  • '); - } - pageButtons.push('
  • Next
  • '); - footerList.innerHTML = pageButtons.join(''); - } - - function applyPagination(resetPage) { - var rows = getRows(); - var matchedRows = rows.filter(isSearchMatched); - var totalItems = matchedRows.length; - var rowHeight = 48; - var pageSize; - var startIndex; - var endIndex; - - if (resetPage) { - state.currentPage = 1; - } - - rows.forEach(function (row) { - if (!isSearchMatched(row)) { - setRowVisible(row, false); - } - }); - - if (!totalItems) { - state.pageSize = 1; - state.totalPages = 1; - state.currentPage = 1; - renderFooter(0); - return; - } - - matchedRows.forEach(function (row) { - setRowVisible(row, true); - }); - - if (matchedRows[0]) { - rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48); - } - - pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight)); - - if (pageSize >= totalItems) { - state.pageSize = totalItems; - state.totalPages = 1; - state.currentPage = 1; - matchedRows.forEach(function (row) { - setRowVisible(row, true); - }); - renderFooter(totalItems); - return; - } - - state.pageSize = pageSize; - state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize)); - state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages); - startIndex = (state.currentPage - 1) * pageSize; - endIndex = startIndex + pageSize; - - matchedRows.forEach(function (row, index) { - setRowVisible(row, index >= startIndex && index < endIndex); - }); - - renderFooter(totalItems); - } - - function scheduleSync(options) { - if (syncFrame) { - window.cancelAnimationFrame(syncFrame); - } - - syncFrame = window.requestAnimationFrame(function () { - syncFrame = 0; - applyPagination(Boolean(options && options.resetPage)); - }); - } - - ensureFooter(); - - if (footer) { - footer.addEventListener('click', function (event) { - var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null; - var pageValue; - - if (!target || target.closest('.disabled')) { - return; - } - - event.preventDefault(); - pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1)); - state.currentPage = pageValue; - scheduleSync(); - }); - } - - window.addEventListener('resize', function () { - scheduleSync(); - }); - - if (typeof ResizeObserver === 'function') { - try { - new ResizeObserver(function () { - scheduleSync(); - }).observe(container); - } catch (_error) { - // Ignore observer setup failures and rely on window resize. - } - } - - container.syncTablePagination = scheduleSync; - scheduleSync({ resetPage: true }); - }); - } - function initTableSearches(root) { - var scope = root && root.querySelectorAll ? root : document; - - Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) { - if (input.getAttribute('data-table-search-bound') === 'true') { - return; - } - - var container = input.closest('[data-table-search-container]') || input.closest('.card') || document; - var table = container.querySelector('[data-table-searchable]'); - var emptyRow = null; - - if (!table) { - return; - } - - input.setAttribute('data-table-search-bound', 'true'); - - function getSearchableRows() { - return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]')); - } - - function getDefaultEmptyRow() { - return table.querySelector('tbody tr[data-table-search-empty-default]'); - } - - function removeGeneratedEmptyRow() { - if (emptyRow && emptyRow.parentNode) { - emptyRow.parentNode.removeChild(emptyRow); - } - emptyRow = null; - } - - function ensureGeneratedEmptyRow(message) { - var tbody = table.tBodies[0] || table.querySelector('tbody'); - var columnCount = 1; - - if (!tbody) { - return; - } - - if (!emptyRow) { - emptyRow = document.createElement('tr'); - emptyRow.setAttribute('data-table-search-empty-row', 'true'); - var cell = document.createElement('td'); - cell.className = 'empty'; - cell.setAttribute('data-table-search-empty-cell', 'true'); - emptyRow.appendChild(cell); - } - - columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1); - emptyRow.firstElementChild.colSpan = Math.max(1, columnCount); - emptyRow.firstElementChild.textContent = message; - - if (!emptyRow.parentNode) { - tbody.appendChild(emptyRow); - } - } - - function syncSearch() { - var query = String(input.value || '').trim().toLowerCase(); - var rows = getSearchableRows(); - var visibleCount = 0; - var defaultEmptyRow = getDefaultEmptyRow(); - - removeGeneratedEmptyRow(); - - rows.forEach(function (row) { - var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase(); - var visible = !query || haystack.indexOf(query) !== -1; - row.setAttribute('data-table-search-match', visible ? 'true' : 'false'); - row.classList.toggle('d-none', !visible); - row.hidden = !visible; - if (visible) { - visibleCount += 1; - } - }); - - if (defaultEmptyRow) { - defaultEmptyRow.classList.toggle('d-none', Boolean(query)); - defaultEmptyRow.hidden = Boolean(query); - } - - if (query && visibleCount === 0) { - ensureGeneratedEmptyRow('No results match your search.'); - } - - if (typeof window.syncTablePagination === 'function') { - window.syncTablePagination(container, { resetPage: true }); - } - } - - input.addEventListener('input', syncSearch); - syncSearch(); - }); + if (typeof window.initTableSearches === 'function') { + window.initTableSearches(root); + } } function createSlideThumbPlaceholder() { @@ -841,10 +545,6 @@ }); } - if (window.initSortableTables) { - window.initSortableTables(); - } - initConfirmForms(); initDirtyTracking(); initCancelConfirm(); @@ -854,19 +554,9 @@ initJsonTogglePanels(); initLocalDateTimes(); initTableSearches(); - initTablePaginations(); attachSlideThumbFallbacks(document); window.initJsonTogglePanels = initJsonTogglePanels; window.initLocalDateTimes = initLocalDateTimes; - window.initTableSearches = initTableSearches; - window.initTablePaginations = initTablePaginations; - window.syncTablePagination = function (container, options) { - if (!container || !container.syncTablePagination) { - return; - } - - container.syncTablePagination(options); - }; window.createSlideThumbPlaceholder = createSlideThumbPlaceholder; window.attachSlideThumbFallbacks = attachSlideThumbFallbacks; }()); diff --git a/src/web/public/js/admin/rbac-permissions.js b/src/web/public/js/admin/rbac-permissions.js deleted file mode 100644 index a2f6138..0000000 --- a/src/web/public/js/admin/rbac-permissions.js +++ /dev/null @@ -1,111 +0,0 @@ -(function () { - function getGroupCheckboxes(group) { - return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]')); - } - - function getPermissionKey(checkbox) { - return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase(); - } - - function getActionKey(checkbox) { - var permissionKey = getPermissionKey(checkbox); - var parts = permissionKey.split('.'); - if (parts.length !== 2) { - return ''; - } - - return String(parts[1] || '').trim().toLowerCase(); - } - - function syncPermissionGroup(group) { - var checkboxes = getGroupCheckboxes(group); - if (!checkboxes.length) { - return; - } - - var readCheckbox = null; - var nonReadChecked = false; - - checkboxes.forEach(function (checkbox) { - if (getActionKey(checkbox) === 'read') { - readCheckbox = checkbox; - return; - } - if (checkbox.checked) { - nonReadChecked = true; - } - }); - - if (!readCheckbox) { - return; - } - - if (!readCheckbox.checked && nonReadChecked) { - readCheckbox.checked = true; - } - - if (!readCheckbox.checked) { - checkboxes.forEach(function (checkbox) { - if (getActionKey(checkbox) !== 'read') { - checkbox.checked = false; - } - }); - } - } - - function handleGroupChange(event) { - var checkbox = event.target && event.target.matches ? event.target : null; - if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') { - return; - } - - var actionKey = getActionKey(checkbox); - var group = checkbox.closest('.accordion-item'); - if (!group) { - return; - } - - var checkboxes = getGroupCheckboxes(group); - var readCheckbox = checkboxes.find(function (candidate) { - return getActionKey(candidate) === 'read'; - }) || null; - - if (!readCheckbox) { - return; - } - - if (actionKey === 'read' && !checkbox.checked) { - checkboxes.forEach(function (candidate) { - if (candidate !== checkbox) { - candidate.checked = false; - } - }); - return; - } - - if (actionKey !== 'read' && checkbox.checked) { - readCheckbox.checked = true; - } - } - - function initPermissionGroups() { - document.querySelectorAll('.accordion-item').forEach(function (group) { - syncPermissionGroup(group); - }); - - document.addEventListener('change', function (event) { - var checkbox = event.target; - if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') { - return; - } - if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') { - return; - } - - handleGroupChange(event); - syncPermissionGroup(checkbox.closest('.accordion-item')); - }); - } - - initPermissionGroups(); -}()); diff --git a/src/web/public/js/admin/system-status.js b/src/web/public/js/admin/system-status.js deleted file mode 100644 index 7458fbd..0000000 --- a/src/web/public/js/admin/system-status.js +++ /dev/null @@ -1,102 +0,0 @@ -(function () { - function updateSidebarStatus(status, label) { - var dot = document.getElementById('sidebar-status-dot'); - var text = document.getElementById('sidebar-status-text'); - var pill = document.getElementById('sidebar-status-pill'); - if (!dot && !text && !pill) { - return; - } - - var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown' - ? status - : (status ? 'online' : 'offline'); - var statusLabel = normalizedStatus === 'online' - ? (label || 'Connected to player feed') - : normalizedStatus === 'offline' - ? 'Disconnected from player feed' - : (label || 'Connecting to player feed'); - var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting'; - - dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline'); - dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown'); - dot.setAttribute('aria-label', statusLabel); - dot.setAttribute('title', statusLabel); - - if (text) { - text.textContent = statusLabel; - } - - if (pill) { - pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline'); - pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown'); - pill.textContent = pillLabel; - } - } - - function connectDashboardSocket() { - if (!window.WebSocket) { - return; - } - - var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill'); - if (!hasStatusIndicators) { - return; - } - - var socket = null; - var reconnectTimer = null; - - function scheduleReconnect() { - if (reconnectTimer) { - return; - } - reconnectTimer = window.setTimeout(function () { - reconnectTimer = null; - connect(); - }, 5000); - } - - function connect() { - var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; - socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard'); - updateSidebarStatus('unknown', 'Connecting to player feed'); - - socket.onmessage = function (event) { - try { - var payload = JSON.parse(String(event.data || '{}')); - if (payload && payload.type === 'dashboard-state') { - if (typeof window.webHandleDashboardState === 'function') { - window.webHandleDashboardState(payload.state); - } - updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed'); - } - } catch (_error) { - // Ignore malformed dashboard payloads. - } - }; - - socket.onclose = function () { - updateSidebarStatus('offline'); - scheduleReconnect(); - }; - - socket.onerror = function () { - updateSidebarStatus('offline'); - try { - socket.close(); - } catch (_error) { - // ignore close errors - } - }; - - socket.onopen = function () { - updateSidebarStatus('unknown', 'Connecting to player feed'); - }; - } - - connect(); - } - - connectDashboardSocket(); -}()); - diff --git a/src/web/public/js/admin/theme-sidebar.js b/src/web/public/js/admin/theme-sidebar.js deleted file mode 100644 index 76f1cd3..0000000 --- a/src/web/public/js/admin/theme-sidebar.js +++ /dev/null @@ -1,171 +0,0 @@ -(function () { - var THEME_STORAGE_KEY = 'web-theme'; - var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides'; - - function getPreferredTheme() { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - return 'dark'; - } - return 'light'; - } - - function getStoredTheme() { - try { - var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); - if (storedTheme === 'dark' || storedTheme === 'light') { - return storedTheme; - } - } catch (error) { - return null; - } - return null; - } - - function setStoredTheme(theme) { - try { - window.localStorage.setItem(THEME_STORAGE_KEY, theme); - } catch (error) { - return; - } - } - - function getOrCreateCkeditorThemeStyleElement() { - var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID); - - if (styleElement) { - return styleElement; - } - - styleElement = document.createElement('style'); - styleElement.id = CKEDITOR_THEME_STYLE_ID; - document.head.appendChild(styleElement); - - return styleElement; - } - - function syncCkeditorTheme(theme) { - var styleElement = getOrCreateCkeditorThemeStyleElement(); - - if (theme !== 'dark') { - styleElement.textContent = ''; - return; - } - - styleElement.textContent = [ - '.ck-body-wrapper .ck.ck-dropdown__panel,', - '.ck-body-wrapper .ck.ck-list__panel,', - '.ck-body-wrapper .ck.ck-list,', - '.ck-body-wrapper .ck.ck-balloon-panel {', - ' background: var(--bs-body-bg) !important;', - ' background-color: var(--bs-body-bg) !important;', - ' border-color: var(--bs-border-color) !important;', - ' color: var(--bs-body-color) !important;', - '}', - '.ck-body-wrapper .ck.ck-list .ck-list-item-button {', - ' background: transparent !important;', - ' background-color: transparent !important;', - ' color: var(--bs-body-color) !important;', - '}', - '.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {', - ' background: var(--bs-secondary-bg) !important;', - ' background-color: var(--bs-secondary-bg) !important;', - '}', - '.ck-body-wrapper .ck.ck-color-grid,', - '.ck-body-wrapper .ck.ck-color-grid__tile {', - ' color: var(--bs-body-color) !important;', - '}', - '.ck-body-wrapper .ck.ck-color-grid__tile {', - ' border-color: var(--bs-border-color) !important;', - '}' - ].join('\n'); - } - - function applyTheme(theme) { - var normalizedTheme = theme === 'dark' ? 'dark' : 'light'; - var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark'; - var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode'; - - document.documentElement.dataset.bsTheme = normalizedTheme; - document.documentElement.style.colorScheme = normalizedTheme; - syncCkeditorTheme(normalizedTheme); - - Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) { - var icon = toggleButton.querySelector('.theme-toggle__icon'); - toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false'); - toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase()); - if (icon) { - icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun'); - icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon'); - } - }); - - return normalizedTheme; - } - - function initThemeToggle() { - var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]')); - var storedTheme = getStoredTheme(); - var theme = storedTheme || getPreferredTheme(); - - applyTheme(theme); - - if (!toggleButtons.length) { - return; - } - - Array.prototype.forEach.call(toggleButtons, function (toggleButton) { - toggleButton.addEventListener('click', function () { - var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark'; - setStoredTheme(nextTheme); - applyTheme(nextTheme); - }); - }); - } - - function initSidebarToggle() { - var body = document.body; - var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]')); - var backdrop = document.querySelector('[data-sidebar-backdrop]'); - - if (!toggleButtons.length || !backdrop) { - return; - } - - function setSidebarOpen(isOpen) { - body.classList.toggle('is-sidebar-open', isOpen); - Array.prototype.forEach.call(toggleButtons, function (toggleButton) { - toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); - }); - } - - function toggleSidebar() { - setSidebarOpen(!body.classList.contains('is-sidebar-open')); - } - - Array.prototype.forEach.call(toggleButtons, function (toggleButton) { - toggleButton.addEventListener('click', function () { - toggleSidebar(); - }); - }); - - backdrop.addEventListener('click', function () { - setSidebarOpen(false); - }); - - document.addEventListener('keydown', function (event) { - if (event.key === 'Escape') { - setSidebarOpen(false); - } - }); - } - - window.getPreferredTheme = getPreferredTheme; - window.getStoredTheme = getStoredTheme; - window.setStoredTheme = setStoredTheme; - window.applyTheme = applyTheme; - window.initThemeToggle = initThemeToggle; - window.initSidebarToggle = initSidebarToggle; - - initThemeToggle(); - initSidebarToggle(); -}()); diff --git a/src/web/public/js/admin/toast.js b/src/web/public/js/admin/toast.js deleted file mode 100644 index 758e268..0000000 --- a/src/web/public/js/admin/toast.js +++ /dev/null @@ -1,134 +0,0 @@ -(function () { - function getBootstrapToast(toast) { - if (!toast || !window.bootstrap || !window.bootstrap.Toast) { - return null; - } - - return window.bootstrap.Toast.getOrCreateInstance(toast, { - autohide: true, - delay: 4000 - }); - } - - function removeToast(toast) { - if (toast && toast.parentNode) { - toast.parentNode.removeChild(toast); - } - } - - function dismissToast(toast) { - var instance = getBootstrapToast(toast); - if (instance) { - instance.hide(); - return; - } - removeToast(toast); - } - - function setToastVariant(toast, variant) { - if (!toast || !toast.classList) { - return; - } - - var nextVariant = String(variant || 'info').trim().toLowerCase(); - var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark']; - variants.forEach(function (value) { - toast.classList.remove('text-bg-' + value); - }); - toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant)); - } - - function getMessageVariant(message, fallbackVariant) { - var text = String(message || '').trim(); - if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) { - return 'danger'; - } - if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) { - return 'warning'; - } - return String(fallbackVariant || 'info').trim().toLowerCase() || 'info'; - } - - function showToast(message, variant) { - var text = String(message || '').trim(); - if (!text) { - return; - } - - var container = document.getElementById('app-toast-container'); - if (!container) { - return; - } - - var existingToast = document.getElementById('app-toast'); - if (existingToast) { - var existingBody = existingToast.querySelector('.toast-body'); - if (existingBody) { - existingBody.textContent = text; - } - var nextVariant = getMessageVariant(text, variant); - existingToast.setAttribute('data-toast-variant', nextVariant); - setToastVariant(existingToast, nextVariant); - var existingInstance = getBootstrapToast(existingToast); - if (existingInstance) { - existingInstance.show(); - } - return; - } - - var toast = document.createElement('div'); - toast.className = 'toast align-items-center border-0'; - toast.id = 'app-toast'; - toast.setAttribute('role', 'status'); - toast.setAttribute('aria-live', 'polite'); - toast.setAttribute('aria-atomic', 'true'); - toast.setAttribute('data-bs-autohide', 'true'); - toast.setAttribute('data-bs-delay', '4000'); - toast.innerHTML = '
    '; - var toastVariant = getMessageVariant(text, variant); - toast.setAttribute('data-toast-variant', toastVariant); - setToastVariant(toast, toastVariant); - toast.querySelector('.toast-body').textContent = text; - - toast.addEventListener('hidden.bs.toast', function () { - removeToast(toast); - }); - - container.appendChild(toast); - var instance = getBootstrapToast(toast); - if (instance) { - instance.show(); - } - } - - function initToast() { - var toast = document.getElementById('app-toast'); - if (!toast) { - return; - } - - try { - var url = new URL(window.location.href); - if (url.searchParams.has('message')) { - url.searchParams.delete('message'); - window.history.replaceState({}, document.title, url.pathname + url.search + url.hash); - } - } catch (_error) { - // ignore URL cleanup failures - } - - var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info'); - setToastVariant(toast, existingVariant); - - var instance = getBootstrapToast(toast); - if (instance) { - instance.show(); - } - } - - window.dismissToast = dismissToast; - window.showToast = showToast; - window.initToast = initToast; - - initToast(); -}()); diff --git a/src/web/public/js/dashboard/dashboard-page.js b/src/web/public/js/dashboard/dashboard-page.js index e89c78c..624afce 100644 --- a/src/web/public/js/dashboard/dashboard-page.js +++ b/src/web/public/js/dashboard/dashboard-page.js @@ -180,18 +180,50 @@ ].join(''); } - function renderScreenRow(screen) { + function renderScreenTile(screen) { var clientCount = Number(screen.player_connection_count || 0); + var playerUrl = String(screen.player_url || '').trim(); + var connectionLabel = clientCount ? clientCount + ' live' : 'No clients'; + var connectionStateClass = clientCount ? 'is-live' : 'is-idle'; + var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned'; + var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected'; + return [ - '', - '' + escapeHtml(screen.name) + '', - '' + escapeHtml(screen.player_url || '') + '', - '' + escapeHtml(screen.playlist_name || '') + '', - '' + (clientCount ? '
    ' + clientCount + ' connected
    ' : 'No clients connected.') + '', - '' + '
    ', + '
    ', + '
    ', + '

    ' + escapeHtml(screen.name || '') + '

    ', + '' + escapeHtml(playerUrl) + '', + '
    ', + '' + escapeHtml(connectionLabel) + '', + '
    ', + '
    ', + '
    Playlist
    ' + playlistLabel + '
    ', + '
    Connections
    ' + escapeHtml(connectionsLabel) + '
    ', + '
    ', + '
    ' ].join(''); } + function compareScreensByConnectedClients(left, right) { + var leftCount = Number(left && left.player_connection_count || 0); + var rightCount = Number(right && right.player_connection_count || 0); + + if (leftCount !== rightCount) { + return rightCount - leftCount; + } + + var leftName = String(left && left.name || '').trim(); + var rightName = String(right && right.name || '').trim(); + var nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true }); + + if (nameCompare !== 0) { + return nameCompare; + } + + return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true }); + } + function updateStats(state) { var clientCount = document.getElementById('dashboard-client-count'); var screenCount = document.getElementById('dashboard-screen-count'); @@ -280,36 +312,52 @@ tbody.removeChild(tbody.lastElementChild); } - window.applyTableSort(document.getElementById('dashboard-clients-table')); } - function updateScreenTable(state) { - var table = document.getElementById('dashboard-screens-table'); - if (!table || !Array.isArray(state.screens)) { + function updateScreenGrid(state) { + var grid = document.getElementById('dashboard-screens-grid'); + if (!grid || !Array.isArray(state.screens)) { return; } - var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null; - if (!tbody) { + var screens = state.screens.slice().sort(compareScreensByConnectedClients); + + if (!screens.length) { + grid.innerHTML = '
    No screens yet.
    '; return; } - if (!state.screens.length) { - tbody.innerHTML = 'No screens yet.'; - return; - } - tbody.innerHTML = state.screens.map(renderScreenRow).join(''); - window.applyTableSort(table); + grid.innerHTML = screens.map(renderScreenTile).join(''); } function updateDashboardQuickActions(state) { + var pauseButton = document.getElementById('dashboard-pause-all-button'); var blackoutButton = document.getElementById('dashboard-blackout-all-button'); - if (!blackoutButton || !state || !Array.isArray(state.clients)) { + if ((!pauseButton && !blackoutButton) || !state || !Array.isArray(state.clients)) { return; } var hasClients = state.clients.length > 0; + var allPaused = hasClients && state.clients.every(function (client) { + return Boolean(client && client.paused); + }); var allBlackout = hasClients && state.clients.every(function (client) { return Boolean(client && client.blackout); }); + if (pauseButton) { + var pauseForm = pauseButton.form; + var pauseInput = pauseForm ? pauseForm.querySelector('input[name="paused"]') : null; + var pauseLabel = allPaused ? 'Resume all clients' : 'Pause all clients'; + var pauseButtonIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill'; + pauseButton.innerHTML = '' + escapeHtml(pauseLabel); + setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info'); + if (pauseInput) { + pauseInput.value = allPaused ? 'false' : 'true'; + } + if (pauseForm) { + pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?'); + } + pauseButton.setAttribute('aria-label', pauseLabel); + } + var label = allBlackout ? 'Restore all clients' : 'Blackout all clients'; var blackoutForm = blackoutButton.form; var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null; @@ -331,7 +379,7 @@ return; } updateStats(state); - updateScreenTable(state); + updateScreenGrid(state); updateClientTable(state); updateDashboardQuickActions(state); } diff --git a/src/web/public/js/settings/background-tasks.js b/src/web/public/js/settings/background-tasks.js index 5dcc18a..69219b9 100644 --- a/src/web/public/js/settings/background-tasks.js +++ b/src/web/public/js/settings/background-tasks.js @@ -1,247 +1,16 @@ (function () { - var REFRESH_INTERVAL_MS = 10000; - var pathname = window.location.pathname.replace(/\/$/, ''); - var stateNode = document.getElementById('background-tasks-state'); - var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : ''; - - if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') { + if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') { return; } - function getPageUrl(href) { - try { - return new URL(href, window.location.href); - } catch (_error) { - return null; - } - } - - function updateLocalDateTimes(root) { - if (typeof window.initLocalDateTimes === 'function') { - window.initLocalDateTimes(root || document); - } - } - - function replaceSectionFromDocument(sectionName, responseDocument) { - var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]'); - var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]'); - - if (!currentCard || !nextCard) { - return false; - } - - currentCard.outerHTML = nextCard.outerHTML; - updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]')); - return true; - } - - function replaceSummaryFromDocument(responseDocument) { - var currentSummary = document.getElementById('background-tasks-summary'); - var nextSummary = responseDocument.getElementById('background-tasks-summary'); - - if (!currentSummary || !nextSummary) { - return false; - } - - currentSummary.outerHTML = nextSummary.outerHTML; - return true; - } - - function replaceFiltersFromDocument(responseDocument) { - var currentFilters = document.getElementById('background-tasks-filters'); - var nextFilters = responseDocument.getElementById('background-tasks-filters'); - - if (!currentFilters || !nextFilters) { - return false; - } - - currentFilters.outerHTML = nextFilters.outerHTML; - return true; - } - - function replaceStateFromDocument(responseDocument) { - var currentState = document.getElementById('background-tasks-state'); - var nextState = responseDocument.getElementById('background-tasks-state'); - - if (!currentState || !nextState) { - return false; - } - - currentState.outerHTML = nextState.outerHTML; - stateNode = document.getElementById('background-tasks-state'); - currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion; - return true; - } - - function applyPageResponse(responseDocument, sectionNames) { - var updated = false; - - if (replaceStateFromDocument(responseDocument)) { - updated = true; - } - - if (replaceSummaryFromDocument(responseDocument)) { - updated = true; - } - - if (replaceFiltersFromDocument(responseDocument)) { - updated = true; - } - - sectionNames.forEach(function (sectionName) { - if (replaceSectionFromDocument(sectionName, responseDocument)) { - updated = true; - } - }); - - return updated; - } - - function loadPage(nextUrl, options) { - var url = getPageUrl(nextUrl); - var sectionNames = options && options.sectionNames ? options.sectionNames : []; - var replaceHistory = Boolean(options && options.replaceHistory); - - if (!url) { + try { + var url = new URL(window.location.href); + if (!url.searchParams.has('message')) { return; } - - fetch(url.toString(), { - method: 'GET', - headers: { - 'Accept': 'text/html, application/xhtml+xml', - 'X-Requested-With': 'XMLHttpRequest' - }, - credentials: 'same-origin', - cache: 'no-store' - }).then(function (response) { - if (!response.ok) { - throw new Error('Unable to load background tasks page.'); - } - return response.text(); - }).then(function (text) { - var responseDocument = new DOMParser().parseFromString(text || '', 'text/html'); - if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) { - return; - } - - if (replaceHistory) { - window.history.replaceState({}, document.title, url.pathname + url.search + url.hash); - } else { - window.history.pushState({}, document.title, url.pathname + url.search + url.hash); - } - }).catch(function (_error) { - window.location.assign(url.pathname + url.search + url.hash); - }); + url.searchParams.delete('message'); + window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash); + } catch (_error) { + // Ignore URL cleanup failures. } - - function stripMessageParameter() { - try { - var url = new URL(window.location.href); - if (!url.searchParams.has('message')) { - return; - } - url.searchParams.delete('message'); - window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash); - } catch (_error) { - // Ignore URL cleanup failures. - } - } - - function findPaginationLink(target) { - if (!target || !target.closest) { - return null; - } - - var link = target.closest('.pagination .page-link'); - if (!link) { - return null; - } - - var href = String(link.getAttribute('href') || '').trim(); - if (!href || href === '#') { - return null; - } - - var card = link.closest('[data-pagination-card]'); - if (!card) { - return null; - } - - return { - href: href, - sectionName: String(card.getAttribute('data-pagination-card') || '').trim() - }; - } - - function initPaginationNavigation() { - document.addEventListener('click', function (event) { - if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) { - return; - } - - var paginationLink = findPaginationLink(event.target); - if (paginationLink && paginationLink.sectionName) { - var currentUrl = getPageUrl(window.location.href); - var nextUrl = getPageUrl(paginationLink.href); - if (!currentUrl || !nextUrl) { - return; - } - - if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) { - event.preventDefault(); - return; - } - - event.preventDefault(); - loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] }); - return; - } - }, true); - - window.addEventListener('popstate', function () { - loadPage(window.location.href, { replaceHistory: true }); - }); - } - - function refreshPage() { - if (document.visibilityState !== 'visible') { - return; - } - - fetch('/settings/background-tasks/state', { - method: 'GET', - headers: { - 'Accept': 'application/json', - 'X-Requested-With': 'XMLHttpRequest' - }, - credentials: 'same-origin', - cache: 'no-store' - }).then(function (response) { - if (!response.ok) { - return null; - } - return response.json(); - }).then(function (payload) { - if (!payload || !payload.version) { - return; - } - - var nextVersion = String(payload.version || ''); - if (!currentVersion) { - currentVersion = nextVersion; - return; - } - - if (nextVersion !== currentVersion) { - window.location.reload(); - } - }).catch(function (_error) { - // Ignore refresh probe errors and try again on the next interval. - }); - } - - stripMessageParameter(); - initPaginationNavigation(); - window.setInterval(refreshPage, REFRESH_INTERVAL_MS); }()); \ No newline at end of file diff --git a/src/web/public/js/slide-form.js b/src/web/public/js/slide-form.js deleted file mode 100644 index ac39b02..0000000 --- a/src/web/public/js/slide-form.js +++ /dev/null @@ -1,1257 +0,0 @@ -import { - Alignment, - BlockQuote, - Bold, - ClassicEditor, - Base64UploadAdapter, - ClipboardPipeline, - Essentials, - FontBackgroundColor, - FontColor, - FontFamily, - FontSize, - Heading, - HorizontalLine, - Image, - ImageBlockEditing, - ImageCaption, - ImageInlineEditing, - ImageInsert, - ImageInsertUI, - ImageInsertViaUrl, - ImageInsertViaUrlUI, - ImageResize, - ImageResizeEditing, - ImageResizeHandles, - ImageStyle, - ImageTextAlternative, - ImageUpload, - Indent, - IndentBlock, - Italic, - InputTextView, - Plugin, - Paragraph, - Strikethrough, - Subscript, - Superscript, - Underline -} from '/assets/js/vendor/ckeditor5/ckeditor5.js'; -import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js'; - -(function () { - var DEFAULT_FONT_SIZE = 32; - - var dataElement = document.getElementById('slide-editor-data'); - if (!dataElement) { - return; - } - - var slideEditorData = {}; - try { - slideEditorData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {}; - } catch (_error) { - slideEditorData = {}; - } - - var templates = Array.isArray(slideEditorData.templates) ? slideEditorData.templates : []; - var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; - var existingContent = slideEditorData.existingContent || {}; - var templateSelect = document.getElementById('template-select'); - var templateFields = document.getElementById('template-fields'); - var slideForm = document.getElementById('slide-form'); - var slideEditorShell = document.querySelector('.slide-editor-shell'); - var slideEditorSidebar = document.querySelector('.slide-editor-sidebar'); - var slidePreviewMeta = document.getElementById('slide-preview-meta'); - var slidePreviewStage = document.getElementById('slide-preview-stage'); - var slidePreviewCanvas = document.getElementById('slide-preview-canvas'); - var slidePreviewBackground = document.getElementById('slide-preview-background'); - var slidePreviewEmpty = document.getElementById('slide-preview-empty'); - var slidePreviewOverlay = document.getElementById('slide-preview-overlay'); - var openPreviewPopupButton = document.getElementById('open-preview-popup'); - var editorInstances = new Map(); - var submitting = false; - var previewRenderFrame = 0; - var previewPopupWindow = null; - var previewPopupRenderFrame = 0; - var currentPreviewCanvasWidth = 0; - var currentPreviewCanvasHeight = 0; - var sidebarSyncFrame = 0; - var sidebarTopOffset = 16; - var templateSelectorLock = createTemplateSelectorLockController(templateSelect); - - class FontSizeInputUI extends Plugin { - static get pluginName() { - return 'FontSizeInputUI'; - } - - init() { - var editor = this.editor; - var fontSizeCommand = editor.commands.get('fontSize'); - var plugin = this; - - if (!fontSizeCommand) { - return; - } - - editor.ui.componentFactory.add('fontSizeInput', function (locale) { - var view = new InputTextView(locale); - - view.extendTemplate({ - attributes: { - class: ['ck-font-size-input'] - } - }); - - view.set({ - placeholder: 'Font size', - ariaLabel: 'Font size' - }); - - function getCurrentFontSizeValue() { - var value = fontSizeCommand.value; - if (value === null || value === undefined || value === '') { - return String(DEFAULT_FONT_SIZE); - } - - var parsed = Math.round(Number(String(value).replace(/[^0-9.]/g, ''))); - return Number.isFinite(parsed) && parsed > 0 ? String(parsed) : ''; - } - - function syncValue() { - if (view.element && document.activeElement === view.element) { - return; - } - - view.value = getCurrentFontSizeValue(); - } - - view.on('render', function () { - if (!view.element) { - return; - } - - view.element.type = 'text'; - view.element.inputMode = 'numeric'; - view.element.autocomplete = 'off'; - view.element.spellcheck = false; - view.element.style.width = '3.5em'; - syncValue(); - }); - - function applyValue() { - if (!view.element) { - return; - } - - var size = Math.max(1, Math.round(Number(view.element.value || 0))); - if (!size) { - return; - } - - editor.execute('fontSize', { value: size + 'px' }); - } - - view.on('input', applyValue); - view.on('blur', applyValue); - - view.on('keydown', function (_eventInfo, domEvent) { - if (domEvent.key === 'Enter') { - domEvent.preventDefault(); - if (view.element) { - view.element.blur(); - } - } - }); - - plugin.listenTo(editor.model.document.selection, 'change:range', syncValue); - plugin.listenTo(fontSizeCommand, 'change:value', syncValue); - - syncValue(); - - return view; - }); - } - } - - class ForcePlainTextPaste extends Plugin { - static get pluginName() { - return 'ForcePlainTextPaste'; - } - - static get requires() { - return [ClipboardPipeline]; - } - - init() { - var editor = this.editor; - var clipboardPipeline = editor.plugins.get(ClipboardPipeline); - - this.listenTo(clipboardPipeline, 'inputTransformation', function (_event, data) { - if (clipboardContainsImageContent(data.dataTransfer)) { - return; - } - - var plainText = data.dataTransfer.getData('text/plain'); - data.content = editor.data.processor.toView(plainTextToHtml(plainText)); - }, { priority: 'high' }); - } - } - - function escapeHtml(value) { - return String(value ?? '') - .replace(/&/g, '&') - .replace(//g, '>') - .replace(/"/g, '"') - .replace(/'/g, '''); - } - - function plainTextToHtml(value) { - var lines = String(value ?? '').replace(/\r\n?/g, '\n').split('\n'); - var paragraphs = []; - var currentParagraph = []; - - lines.forEach(function (line) { - if (line === '') { - if (currentParagraph.length) { - paragraphs.push('

    ' + currentParagraph.join('
    ') + '

    '); - currentParagraph = []; - } else { - paragraphs.push('

    '); - } - return; - } - - currentParagraph.push(escapeHtml(line)); - }); - - if (currentParagraph.length) { - paragraphs.push('

    ' + currentParagraph.join('
    ') + '

    '); - } - - return paragraphs.length ? paragraphs.join('') : '

    '; - } - - function clipboardContainsImageContent(dataTransfer) { - if (!dataTransfer) { - return false; - } - - var files = Array.from(dataTransfer.files || []); - if (files.some(function (file) { - return file && String(file.type || '').indexOf('image/') === 0; - })) { - return true; - } - - var html = dataTransfer.getData('text/html') || ''; - return /]*\bclass=["'][^"']*\bimage\b/i.test(html); - } - - function getTemplateById(id) { - return templates.find(function (item) { return Number(item.id) === Number(id); }) || null; - } - - function getCurrentRegionValue(region) { - var current = existingContent[region.region_key]; - return current && current.value !== undefined ? current.value : ''; - } - - function normalizeEditorData(value) { - return String(value || ''); - } - - function requestPreviewRender() { - if (previewRenderFrame) { - return; - } - - previewRenderFrame = window.requestAnimationFrame(function () { - previewRenderFrame = 0; - renderPreview(); - }); - } - - function getTemplateFieldControls() { - return templateFields ? templateFields.querySelectorAll('input, textarea, select') : []; - } - - function sanitizePreviewHtml(html) { - var container = document.createElement('div'); - container.innerHTML = String(html || ''); - return sanitizePreviewNode(container); - } - - function sanitizePreviewNode(node) { - var allowed = ['a', 'b', 'blockquote', 'br', 'code', 'div', 'em', 'figure', 'figcaption', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr', 'i', 'img', 'li', 'ol', 'p', 'pre', 'span', 'strong', 'sub', 'sup', 'table', 'tbody', 'td', 'th', 'thead', 'tr', 'u', 'ul']; - var output = ''; - - Array.from(node.childNodes || []).forEach(function (child) { - if (child.nodeType === Node.TEXT_NODE) { - output += escapeHtml(child.textContent); - return; - } - - if (child.nodeType !== Node.ELEMENT_NODE) { - return; - } - - var name = String(child.tagName || '').toLowerCase(); - if (allowed.indexOf(name) === -1) { - return; - } - - if (name === 'br' || name === 'hr') { - output += '<' + name + '>'; - return; - } - - var attrs = sanitizePreviewElementAttributes(child, name); - if (name === 'img') { - output += ''; - return; - } - - output += '<' + name + attrs + '>' + sanitizePreviewNode(child) + ''; - }); - - return output; - } - - function sanitizePreviewElementAttributes(element, tagName) { - var allowedAttributes = { - a: ['href', 'title', 'target', 'rel', 'class', 'style'], - blockquote: ['class', 'style'], - div: ['class', 'style'], - figure: ['class', 'style'], - figcaption: ['class', 'style'], - h1: ['class', 'style'], - h2: ['class', 'style'], - h3: ['class', 'style'], - h4: ['class', 'style'], - h5: ['class', 'style'], - h6: ['class', 'style'], - img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'], - li: ['class', 'style'], - ol: ['class', 'style', 'start'], - p: ['class', 'style'], - pre: ['class', 'style'], - span: ['class', 'style'], - table: ['class', 'style'], - td: ['class', 'style', 'colspan', 'rowspan'], - th: ['class', 'style', 'colspan', 'rowspan', 'scope'], - tr: ['class', 'style'], - ul: ['class', 'style'] - }; - var allowed = allowedAttributes[tagName] || []; - var attrs = []; - - Array.from(element.attributes || []).forEach(function (attribute) { - var lowerKey = String(attribute.name || '').toLowerCase(); - if (allowed.indexOf(lowerKey) === -1) { - return; - } - - var value = String(attribute.value || ''); - if (tagName === 'img' && lowerKey === 'src') { - value = sanitizeImageSrc(value); - } - if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(value)) { - return; - } - if (tagName === 'img' && lowerKey === 'src' && !value) { - return; - } - if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(value)) { - return; - } - if (lowerKey === 'target') { - var targetValue = value.trim(); - if (targetValue === '_blank') { - attrs.push(' target="_blank"'); - if (attrs.indexOf(' rel="noreferrer noopener"') === -1) { - attrs.push(' rel="noreferrer noopener"'); - } - return; - } - } - attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); - }); - - return attrs.join(''); - } - - function sanitizeTagAttributes(tagName, attrText) { - var allowedAttributes = { - a: ['href', 'title', 'target', 'rel', 'class', 'style'], - blockquote: ['class', 'style'], - div: ['class', 'style'], - figure: ['class', 'style'], - figcaption: ['class', 'style'], - h1: ['class', 'style'], - h2: ['class', 'style'], - h3: ['class', 'style'], - h4: ['class', 'style'], - h5: ['class', 'style'], - h6: ['class', 'style'], - img: ['alt', 'class', 'decoding', 'height', 'loading', 'src', 'style', 'title', 'width'], - li: ['class', 'style'], - ol: ['class', 'style', 'start'], - p: ['class', 'style'], - pre: ['class', 'style'], - span: ['class', 'style'], - table: ['class', 'style'], - td: ['class', 'style', 'colspan', 'rowspan'], - th: ['class', 'style', 'colspan', 'rowspan', 'scope'], - tr: ['class', 'style'], - ul: ['class', 'style'] - }; - var allowed = allowedAttributes[tagName] || []; - if (!allowed.length) { - return ''; - } - - var attrs = []; - attrText.replace(/([a-zA-Z0-9:-]+)(?:\s*=\s*("([^"]*)"|'([^']*)'|([^\s"'>/=`]+)))?/g, function (_full, key, _valuePart, doubleQuoted, singleQuoted, bareValue) { - var lowerKey = String(key || '').toLowerCase(); - if (allowed.indexOf(lowerKey) === -1) { - return ''; - } - var value = doubleQuoted !== undefined ? doubleQuoted : singleQuoted !== undefined ? singleQuoted : bareValue !== undefined ? bareValue : ''; - if (tagName === 'img' && lowerKey === 'src') { - value = sanitizeImageSrc(value); - } - if (lowerKey === 'href' && /^(?:\s*javascript:|\s*data:)/i.test(String(value || ''))) { - return ''; - } - if (tagName === 'img' && lowerKey === 'src' && !value) { - return ''; - } - if (lowerKey === 'style' && /(?:expression\s*\(|javascript:|url\s*\()/i.test(String(value || ''))) { - return ''; - } - if (lowerKey === 'target') { - var targetValue = String(value || '').trim(); - if (targetValue === '_blank') { - attrs.push(' target="_blank"'); - if (attrs.indexOf(' rel="noreferrer noopener"') === -1) { - attrs.push(' rel="noreferrer noopener"'); - } - return ''; - } - } - attrs.push(' ' + lowerKey + '="' + escapeHtml(value) + '"'); - return ''; - }); - - return attrs.join(''); - } - - function sanitizeImageSrc(value) { - var raw = String(value || '').trim(); - if (!raw) { - return ''; - } - - if (/^(?:https?:|\/|\.\.?\/|\/\/)/i.test(raw)) { - return raw; - } - - if (/^data:image\/(?:png|jpe?g|gif|webp|bmp);base64,/i.test(raw)) { - return raw; - } - - return ''; - } - - function renderPreviewListItem(item, tag) { - if (item && typeof item === 'object') { - var content = item.content !== undefined ? item.content : (item.text !== undefined ? item.text : item.value !== undefined ? item.value : ''); - var children = Array.isArray(item.items) ? item.items : Array.isArray(item.subItems) ? item.subItems : Array.isArray(item.children) ? item.children : []; - var nested = children.length ? '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + children.map(function (child) { return renderPreviewListItem(child, tag); }).join('') + '' : ''; - return '
  • ' + sanitizePreviewHtml(content || '') + nested + '
  • '; - } - return '
  • ' + sanitizePreviewHtml(item || '') + '
  • '; - } - - function renderPreviewTable(data) { - var rows = Array.isArray(data.content) ? data.content : Array.isArray(data.rows) ? data.rows : []; - if (!rows.length) { - return ''; - } - var hasHeadings = Boolean(data.withHeadings); - var body = rows.map(function (row, rowIndex) { - var cells = Array.isArray(row) ? row : []; - var cellTag = hasHeadings && rowIndex === 0 ? 'th' : 'td'; - var cellAttrs = cellTag === 'th' ? ' scope="col"' : ''; - return '' + cells.map(function (cell) { - return '<' + cellTag + cellAttrs + '>' + sanitizePreviewHtml(cell || '') + ''; - }).join('') + ''; - }).join(''); - return '' + body + '
    '; - } - - function renderPreviewBlock(block) { - if (!block || !block.type || !block.data) { - return ''; - } - if (block.type === 'header') { - var level = Math.max(1, Math.min(6, Number(block.data.level || 2))); - return '' + sanitizePreviewHtml(block.data.text || '') + ''; - } - if (block.type === 'list') { - var tag = block.data.style === 'ordered' ? 'ol' : 'ul'; - var items = Array.isArray(block.data.items) ? block.data.items : []; - return '<' + tag + ' style="list-style-type:' + (tag === 'ol' ? 'decimal' : 'disc') + ';padding-left:1.4em;">' + items.map(function (item) { return renderPreviewListItem(item, tag); }).join('') + ''; - } - if (block.type === 'delimiter') { - return '
    '; - } - if (block.type === 'code') { - return '
    ' + sanitizePreviewHtml(block.data.code || '') + '
    '; - } - if (block.type === 'table') { - return renderPreviewTable(block.data); - } - if (block.type === 'paragraph') { - return '

    ' + sanitizePreviewHtml(block.data.text || '') + '

    '; - } - return ''; - } - - function renderPreviewText(value) { - var raw = String(value || ''); - if (!raw) { - return '
    Empty text
    '; - } - return sanitizePreviewHtml(raw); - } - - function renderPreviewTextRegion(region, value, style, scale) { - var fontFamily = style.font_family ? 'font-family:' + escapeHtml(style.font_family) + ';' : ''; - var color = style.font_color ? 'color:' + escapeHtml(style.font_color) + ';' : ''; - var fontSize = style.font_size ? 'font-size:' + Math.max(1, Math.round(Number(style.font_size))) + 'px;' : ''; - var width = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1)); - var height = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1)); - var wrapperStyle = 'width:' + width + 'px;height:' + height + 'px;overflow:hidden;' + fontFamily + fontSize + color; - return '
    ' + renderPreviewText(value) + '
    '; - } - - function renderPreviewWebpageRegion(value) { - var src = String(value || '').trim(); - if (!src) { - return '
    Webpage
    '; - } - return ''; - } - - function renderPreviewHtmlRegion(value) { - var html = String(value || '').trim(); - if (!html) { - return '
    HTML
    '; - } - return ''; - } - - function destroyEditors() { - editorInstances.forEach(function (editor) { - if (editor && typeof editor.destroy === 'function') { - editor.destroy(); - } - }); - editorInstances.clear(); - } - - function getEditorHiddenInput(regionId) { - var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); - return card ? card.querySelector('input[type="hidden"][name="region_text_' + regionId + '"]') : null; - } - - function getEditorFontSizeHiddenInput(regionId) { - var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); - return card ? card.querySelector('input[name="region_font_size_' + regionId + '"]') : null; - } - - function normalizeFontSizeValue(value) { - var size = Math.max(1, Math.round(Number(String(value || '').replace(/[^0-9.]/g, '')))); - return size ? String(size) : ''; - } - - function getCurrentTextStyle(region) { - var current = existingContent[region.region_key] || {}; - return { - font_family: String(current.font_family || region.font_family || 'Arial').trim() || 'Arial', - font_size: Math.max(8, Number(current.font_size || DEFAULT_FONT_SIZE)), - font_color: String(current.font_color || region.font_color || '#000000').trim() || '#000000' - }; - } - - function getTextStyleFromCard(_card, region) { - return getCurrentTextStyle(region); - } - - function getSelectedTemplate() { - return templates.find(function (item) { return Number(item.id) === Number(templateSelect.value); }) || null; - } - - function clearImagePreviewUrls() { - templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) { - if (input.dataset.previewUrl) { - URL.revokeObjectURL(input.dataset.previewUrl); - delete input.dataset.previewUrl; - } - }); - } - - function getRegionBox(region, canvasWidth, canvasHeight) { - return { - left: Math.max(0, Math.round(Number(region.x || 0))), - top: Math.max(0, Math.round(Number(region.y || 0))), - width: Math.max(1, Math.round(Number(region.width || 0) || 1)), - height: Math.max(1, Math.round(Number(region.height || 0) || 1)) - }; - } - - function getPreviewCanvasDimensions(template) { - return { - width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)), - height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080)) - }; - } - - function buildPreviewPopupHtml() { - return '' + - '' + - '' + - '' + - '' + - 'Slide preview' + - '' + - '' + - '' + - '' + - '' + - ''; - } - - function syncPopupPreview() { - if (!previewPopupWindow || previewPopupWindow.closed) { - previewPopupWindow = null; - return; - } - - var popupDocument = previewPopupWindow.document; - if (!popupDocument) { - return; - } - - var popupStage = popupDocument.getElementById('popup-preview-stage'); - var popupCanvas = popupDocument.getElementById('popup-preview-canvas'); - var template = getSelectedTemplate(); - - if (!popupStage || !popupCanvas) { - if (previewPopupRenderFrame) { - cancelAnimationFrame(previewPopupRenderFrame); - } - previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); - return; - } - - if (!template) { - popupStage.style.backgroundColor = '#111111'; - popupCanvas.style.width = ''; - popupCanvas.style.height = ''; - popupCanvas.style.transform = ''; - popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; - var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); - var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); - var popupBackground = popupCanvas.querySelector('#slide-preview-background'); - if (popupBackground) { - popupBackground.style.display = 'none'; - } - if (popupEmpty) { - popupEmpty.style.display = 'flex'; - popupEmpty.textContent = 'Select a template to preview the slide.'; - } - if (popupOverlay) { - popupOverlay.innerHTML = ''; - } - return; - } - - var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920)); - var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080)); - popupStage.style.backgroundColor = template.background_color || '#111111'; - popupCanvas.style.width = canvasWidth + 'px'; - popupCanvas.style.height = canvasHeight + 'px'; - popupCanvas.innerHTML = slidePreviewCanvas.innerHTML; - var popupBackground = popupCanvas.querySelector('#slide-preview-background'); - var popupEmpty = popupCanvas.querySelector('#slide-preview-empty'); - var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay'); - - if (popupBackground) { - popupBackground.style.display = template.background_image_path ? 'block' : 'none'; - popupBackground.src = template.background_image_path || ''; - } - if (popupEmpty) { - popupEmpty.style.display = 'none'; - } - - var stageRect = popupStage.getBoundingClientRect(); - if (!stageRect.width || !stageRect.height) { - if (previewPopupRenderFrame) { - cancelAnimationFrame(previewPopupRenderFrame); - } - previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview); - return; - } - - var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; - popupCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; - } - - function openPreviewPopup() { - if (previewPopupWindow && !previewPopupWindow.closed) { - previewPopupWindow.focus(); - syncPopupPreview(); - return; - } - - var popupWidth = 1280; - var popupHeight = 900; - var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2)); - var top = Math.max(0, Math.round((window.screen.height - popupHeight) / 2)); - var popupFeatures = [ - 'popup=yes', - 'toolbar=no', - 'location=no', - 'menubar=no', - 'directories=no', - 'status=no', - 'scrollbars=yes', - 'resizable=yes', - 'width=' + popupWidth, - 'height=' + popupHeight, - 'left=' + left, - 'top=' + top - ].join(','); - - previewPopupWindow = window.open('', 'slide-preview-popup', popupFeatures); - if (!previewPopupWindow) { - return; - } - - previewPopupWindow.document.open(); - previewPopupWindow.document.write(buildPreviewPopupHtml()); - previewPopupWindow.document.close(); - previewPopupWindow.addEventListener('resize', syncPopupPreview); - previewPopupWindow.focus(); - syncPopupPreview(); - } - - function syncSlideEditorSidebar() { - sidebarSyncFrame = 0; - - if (!slideEditorShell || !slideEditorSidebar) { - return; - } - - if (window.matchMedia && window.matchMedia('(max-width: 1199.98px)').matches) { - slideEditorSidebar.style.transform = ''; - return; - } - - var shellRect = slideEditorShell.getBoundingClientRect(); - var sidebarHeight = slideEditorSidebar.offsetHeight; - var shellTop = window.scrollY + shellRect.top; - var shellBottom = shellTop + slideEditorShell.offsetHeight; - var maxTranslate = Math.max(0, shellBottom - shellTop - sidebarHeight - sidebarTopOffset); - var desiredTranslate = Math.max(0, Math.min(window.scrollY + sidebarTopOffset - shellTop, maxTranslate)); - - slideEditorSidebar.style.transform = desiredTranslate ? 'translate3d(0, ' + desiredTranslate + 'px, 0)' : ''; - } - - function requestSidebarSync() { - if (sidebarSyncFrame) { - return; - } - - sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar); - } - - function renderPreview() { - var template = getSelectedTemplate(); - if (!template) { - slidePreviewMeta.textContent = '--'; - slidePreviewBackground.style.display = 'none'; - slidePreviewEmpty.style.display = 'flex'; - slidePreviewEmpty.textContent = 'Select a template to preview the slide.'; - slidePreviewCanvas.style.width = ''; - slidePreviewCanvas.style.height = ''; - slidePreviewOverlay.innerHTML = ''; - slidePreviewOverlay.style.width = ''; - slidePreviewOverlay.style.height = ''; - slidePreviewOverlay.style.transform = ''; - slidePreviewStage.style.aspectRatio = '16 / 9'; - slidePreviewStage.style.backgroundColor = '#111111'; - return; - } - - var canvasSize = getPreviewCanvasDimensions(template); - var canvasWidth = canvasSize.width; - var canvasHeight = canvasSize.height; - currentPreviewCanvasWidth = canvasWidth; - currentPreviewCanvasHeight = canvasHeight; - slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight; - slidePreviewStage.style.backgroundColor = template.background_color || '#111111'; - slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight; - if (!(template.regions || []).length) { - slidePreviewEmpty.style.display = 'flex'; - slidePreviewEmpty.textContent = 'This template has no regions.'; - slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none'; - slidePreviewBackground.src = template.background_image_path || ''; - slidePreviewCanvas.style.width = canvasWidth + 'px'; - slidePreviewCanvas.style.height = canvasHeight + 'px'; - slidePreviewCanvas.style.transform = ''; - slidePreviewOverlay.innerHTML = ''; - slidePreviewOverlay.style.width = ''; - slidePreviewOverlay.style.height = ''; - slidePreviewOverlay.style.transform = ''; - return; - } - - slidePreviewEmpty.style.display = 'none'; - slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none'; - slidePreviewBackground.src = template.background_image_path || ''; - - var stageRect = slidePreviewStage.getBoundingClientRect(); - if (!stageRect.width || !stageRect.height) { - if (previewRenderFrame) { - cancelAnimationFrame(previewRenderFrame); - } - previewRenderFrame = requestAnimationFrame(function () { - previewRenderFrame = 0; - renderPreview(); - }); - return; - } - var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1; - slidePreviewCanvas.style.width = canvasWidth + 'px'; - slidePreviewCanvas.style.height = canvasHeight + 'px'; - slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')'; - slidePreviewCanvas.style.transformOrigin = 'top left'; - var regions = template.regions || []; - slidePreviewOverlay.innerHTML = regions.map(function (region) { - var box = getRegionBox(region, canvasWidth, canvasHeight); - var card = templateFields.querySelector('[data-region-id="' + region.id + '"]'); - var hiddenInput = card && card.querySelector('input[type="hidden"][name="region_text_' + region.id + '"]'); - var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]'); - var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]'); - var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]'); - var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]'); - var value = region.region_type === 'image' - ? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : '')) - : region.region_type === 'webpage' - ? (webpageInput ? webpageInput.value : '') - : region.region_type === 'html' - ? (htmlInput ? htmlInput.value : '') - : (hiddenInput ? hiddenInput.value : ''); - var style = region.region_type === 'image' - ? null - : region.region_type === 'webpage' - ? null - : region.region_type === 'html' - ? null - : getTextStyleFromCard(card, region); - var content = region.region_type === 'image' - ? (value ? '' : '
    Image
    ') - : region.region_type === 'webpage' - ? renderPreviewWebpageRegion(value) - : region.region_type === 'html' - ? renderPreviewHtmlRegion(value) - : renderPreviewTextRegion(region, value, style, scale); - var selected = region.region_type === 'image' - ? ' slide-preview-image-region' - : region.region_type === 'webpage' - ? ' slide-preview-webpage-region' - : region.region_type === 'html' - ? ' slide-preview-html-region' - : ' slide-preview-text-region'; - return '
    ' + content + '
    '; - }).join(''); - - syncPopupPreview(); - } - - function renderTextRegion(region) { - var current = getCurrentRegionValue(region); - return '' + - '
    ' + - '
    ' + - '' + escapeHtml(region.label) + '' + - '
    ' + - '' + - 'Text' + - '
    ' + - '
    ' + - '
    ' + - '
    ' + - '' + - '
    ' + - '' + - '' + - '
    ' + - '
    '; - } - - function renderWebpageRegion(region) { - var current = getCurrentRegionValue(region); - return '' + - '
    ' + - '
    ' + - '' + escapeHtml(region.label) + '' + - 'Webpage' + - '
    ' + - '
    ' + - '' + - '
    The webpage will be loaded in an iframe.
    ' + - '
    ' + - '
    '; - } - - function renderHtmlRegion(region) { - var current = getCurrentRegionValue(region); - return '' + - '
    ' + - '
    ' + - '' + escapeHtml(region.label) + '' + - 'HTML' + - '
    ' + - '
    ' + - '' + - '
    HTML is rendered in a sandboxed iframe preview.
    ' + - '
    ' + - '
    '; - } - - function renderImageRegion(region) { - var current = getCurrentRegionValue(region); - return '' + - '
    ' + - '
    ' + - '' + escapeHtml(region.label) + '' + - 'Image' + - '
    ' + - '
    ' + - '' + - '' + - (current - ? '' - : '
    No image selected.
    ') + - '
    ' + - '
    '; - } - - function renderTemplate() { - templateSelectorLock.reset(); - clearImagePreviewUrls(); - destroyEditors(); - var template = getTemplateById(templateSelect.value); - if (!template) { - templateFields.innerHTML = ''; - requestPreviewRender(); - return; - } - - templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) { - if (region.region_type === 'image') { - return renderImageRegion(region); - } - if (region.region_type === 'webpage') { - return renderWebpageRegion(region); - } - if (region.region_type === 'html') { - return renderHtmlRegion(region); - } - return renderTextRegion(region); - }).join('') : '
    This template has no regions.
    '; - - templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) { - var regionId = holder.getAttribute('data-region-id'); - var source = holder.querySelector('.ckeditor-source'); - var hidden = getEditorHiddenInput(regionId); - var editorConfig = { - licenseKey: 'GPL', - placeholder: 'Write content...', - toolbar: { - items: [ - 'undo', - 'redo', - '|', - 'fontSizeInput', - 'fontFamily', - 'fontColor', - 'fontBackgroundColor', - '|', - 'bold', - 'italic', - 'underline', - 'strikethrough', - 'subscript', - 'superscript', - '|', - 'horizontalLine', - 'blockQuote', - '|', - 'alignment', - '|', - 'outdent', - 'indent', - '|', - 'imageInsert', - 'imageInsertViaUrl', - 'imageStyle:block', - 'imageStyle:inline', - 'imageTextAlternative', - 'imageResize' - ], - shouldNotGroupWhenFull: false - }, - plugins: [ - Alignment, - Base64UploadAdapter, - BlockQuote, - Bold, - Essentials, - ForcePlainTextPaste, - FontBackgroundColor, - FontColor, - FontFamily, - FontSize, - FontSizeInputUI, - HorizontalLine, - Indent, - IndentBlock, - Italic, - Image, - ImageBlockEditing, - ImageCaption, - ImageInlineEditing, - ImageInsert, - ImageInsertUI, - ImageInsertViaUrl, - ImageInsertViaUrlUI, - ImageResize, - ImageResizeEditing, - ImageResizeHandles, - ImageStyle, - ImageTextAlternative, - ImageUpload, - Paragraph, - Strikethrough, - Subscript, - Superscript, - Underline - ], - fontFamily: { - supportAllValues: true - }, - fontSize: { - options: [20, 24, 28, 32, 36, 40, 44], - supportAllValues: true - }, - initialData: normalizeEditorData((source && source.value) || (hidden ? hidden.value : '')) - }; - - ClassicEditor.create(source || holder, editorConfig).then(function (editor) { - editorInstances.set(regionId, editor); - var fontSizeHidden = getEditorFontSizeHiddenInput(regionId); - var fontSizeCommand = editor.commands.get('fontSize'); - if (hidden) { - hidden.value = editor.getData(); - } - if (fontSizeHidden && fontSizeCommand) { - fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value; - fontSizeCommand.on('change:value', function () { - fontSizeHidden.value = normalizeFontSizeValue(fontSizeCommand.value) || fontSizeHidden.value; - }); - } - editor.model.document.on('change:data', function () { - var instance = editorInstances.get(regionId); - if (instance !== editor) { - return; - } - if (hidden) { - hidden.value = editor.getData(); - } - templateSelectorLock.markEdited(); - requestPreviewRender(); - }); - requestPreviewRender(); - }).catch(function (error) { - console.error('Failed to initialize CKEditor.', error); - }); - }); - - templateFields.querySelectorAll('[data-toggle-editor-height]').forEach(function (button) { - button.addEventListener('click', function () { - var regionId = button.getAttribute('data-toggle-editor-height'); - var card = templateFields.querySelector('[data-region-id="' + regionId + '"]'); - if (!card) { - return; - } - - var expanded = card.classList.toggle('is-expanded'); - button.textContent = expanded ? 'Collapse' : 'Expand'; - }); - }); - - templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) { - input.addEventListener('change', function () { - if (input.dataset.previewUrl) { - URL.revokeObjectURL(input.dataset.previewUrl); - delete input.dataset.previewUrl; - } - if (input.files && input.files.length) { - input.dataset.previewUrl = URL.createObjectURL(input.files[0]); - } - templateSelectorLock.markEdited(); - requestPreviewRender(); - }); - }); - window.requestAnimationFrame(function () { - templateSelectorLock.arm(); - }); - requestPreviewRender(); - } - - slideForm.addEventListener('submit', async function (event) { - if (submitting) { - return; - } - event.preventDefault(); - var submitterValue = event.submitter && event.submitter.name === 'save_action' - ? String(event.submitter.value || '').trim().toLowerCase() - : ''; - var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) { - var regionId = holder.getAttribute('data-region-id'); - var editor = editorInstances.get(regionId); - var hidden = getEditorHiddenInput(regionId); - if (!editor || !hidden) { - return Promise.resolve(); - } - hidden.value = editor.getData(); - return Promise.resolve(); - }); - submitting = true; - try { - await Promise.all(saves); - var formData = new FormData(slideForm); - if (event.submitter && event.submitter.name) { - formData.set(event.submitter.name, event.submitter.value || ''); - } - var response = await fetch(slideForm.action, { - method: (slideForm.method || 'POST').toUpperCase(), - headers: { - 'X-Requested-With': 'XMLHttpRequest', - 'Accept': 'text/html, application/json, text/plain, */*' - }, - body: formData, - credentials: 'same-origin' - }); - - if (!response.ok) { - throw new Error(await response.text() || 'Unable to save slide.'); - } - - if (submitterValue === 'close' || submitterValue === 'new') { - var redirectUrl = submitterValue === 'close' - ? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action) - : String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action); - window.location.replace(redirectUrl); - return; - } - - var responseText = await response.text(); - var savedMessage = ''; - try { - var doc = new DOMParser().parseFromString(responseText || '', 'text/html'); - var toastBody = doc.querySelector('.toast-body'); - if (toastBody && toastBody.textContent) { - savedMessage = toastBody.textContent.trim(); - } - } catch (_error) { - savedMessage = ''; - } - - showToast(savedMessage || 'Saved slide.', 'success'); - slideForm.dataset.dirty = 'false'; - } finally { - submitting = false; - } - }); - - slideForm.addEventListener('input', function () { - slideForm.dataset.dirty = 'true'; - }, true); - - slideForm.addEventListener('change', function () { - slideForm.dataset.dirty = 'true'; - }, true); - - if (openPreviewPopupButton) { - openPreviewPopupButton.addEventListener('click', function () { - openPreviewPopup(); - }); - } - - templateSelect.addEventListener('change', renderTemplate); - templateFields.addEventListener('change', function () { - templateSelectorLock.markEdited(); - requestPreviewRender(); - }); - templateFields.addEventListener('input', function () { - templateSelectorLock.markEdited(); - requestPreviewRender(); - }); - if (existingTemplateId) { - templateSelect.value = String(existingTemplateId); - } - renderTemplate(); - if (window.ResizeObserver && slidePreviewStage) { - var previewResizeObserver = new ResizeObserver(function () { - requestPreviewRender(); - }); - previewResizeObserver.observe(slidePreviewStage); - } - requestSidebarSync(); - window.addEventListener('scroll', requestSidebarSync, { passive: true }); - window.addEventListener('resize', requestPreviewRender); - window.addEventListener('resize', requestSidebarSync); - window.addEventListener('beforeunload', function () { - if (previewPopupWindow && !previewPopupWindow.closed) { - previewPopupWindow.close(); - } - }); -})(); diff --git a/src/web/public/js/table-sort.js b/src/web/public/js/table-sort.js deleted file mode 100644 index 3c80de0..0000000 --- a/src/web/public/js/table-sort.js +++ /dev/null @@ -1,242 +0,0 @@ -(function () { - function getTableHeaderText(headerCell) { - return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase(); - } - - function cellHasButtonContent(cell) { - return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea')); - } - - function getCellSortValue(cell) { - if (!cell) { - return ''; - } - var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value'); - if (sortValue !== null && sortValue !== undefined && sortValue !== '') { - return String(sortValue).trim(); - } - return String(cell.textContent || '').replace(/\s+/g, ' ').trim(); - } - - function getComparableSortValue(rawValue) { - var value = String(rawValue || '').trim(); - if (!value) { - return { type: 'empty', value: '' }; - } - - var numericValue = Number(value.replace(/,/g, '')); - if (!Number.isNaN(numericValue) && value !== '') { - return { type: 'number', value: numericValue }; - } - - var dateValue = Date.parse(value); - if (!Number.isNaN(dateValue)) { - return { type: 'date', value: dateValue }; - } - - return { type: 'string', value: value.toLowerCase() }; - } - - function compareSortValues(leftValue, rightValue) { - if (leftValue.type === 'empty' && rightValue.type === 'empty') { - return 0; - } - if (leftValue.type === 'empty') { - return 1; - } - if (rightValue.type === 'empty') { - return -1; - } - if (leftValue.type === rightValue.type) { - if (leftValue.value < rightValue.value) { - return -1; - } - if (leftValue.value > rightValue.value) { - return 1; - } - return 0; - } - return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' }); - } - - function getSortableTableState(table) { - if (!table._sortableState) { - table._sortableState = { - columnIndex: null, - direction: 'asc' - }; - } - return table._sortableState; - } - - function ensureSortableHeaderIndicator(headerCell) { - var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator'); - if (indicator) { - return indicator; - } - - indicator = document.createElement('i'); - indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1'; - indicator.setAttribute('aria-hidden', 'true'); - headerCell.appendChild(indicator); - return indicator; - } - - function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) { - if (!isSortable) { - var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator'); - if (hiddenIndicator) { - hiddenIndicator.style.display = 'none'; - } - return; - } - - var indicator = ensureSortableHeaderIndicator(headerCell); - indicator.style.display = ''; - indicator.className = 'table-sort-indicator bi ms-1'; - - if (isActive && direction === 'desc') { - indicator.classList.add('bi-caret-down-fill'); - return; - } - - if (isActive && direction === 'asc') { - indicator.classList.add('bi-caret-up-fill'); - return; - } - - indicator.classList.add('bi-arrow-down-up'); - } - - function isSortableTableColumn(table, columnIndex) { - var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null; - if (!headerCell) { - return false; - } - - if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') { - return false; - } - - if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) { - return false; - } - - var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : []; - for (var i = 0; i < bodies.length; i += 1) { - var rows = Array.prototype.slice.call(bodies[i].rows || []); - for (var j = 0; j < rows.length; j += 1) { - var cell = rows[j].cells ? rows[j].cells[columnIndex] : null; - if (cell && cellHasButtonContent(cell)) { - return false; - } - } - } - - return true; - } - - function updateSortableHeaderState(table) { - var state = getSortableTableState(table); - var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : []; - headerCells.forEach(function (headerCell, index) { - if (!headerCell) { - return; - } - var sortable = isSortableTableColumn(table, index); - headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable'); - headerCell.removeAttribute('aria-sort'); - headerCell.removeAttribute('role'); - headerCell.removeAttribute('tabindex'); - - if (sortable) { - headerCell.classList.add('sortable'); - updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction); - headerCell.setAttribute('role', 'button'); - headerCell.setAttribute('tabindex', '0'); - if (state.columnIndex === index) { - headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc'); - headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending'); - } else { - headerCell.setAttribute('aria-sort', 'none'); - } - } else { - headerCell.classList.add('unsortable'); - updateSortableHeaderIndicator(headerCell, false); - } - }); - } - - function sortTable(table, columnIndex, direction) { - var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null; - if (!tbody) { - return; - } - - var rows = Array.prototype.slice.call(tbody.rows || []); - if (!rows.length) { - return; - } - - var multiplier = direction === 'desc' ? -1 : 1; - rows.sort(function (leftRow, rightRow) { - var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null; - var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null; - var leftComparable = getComparableSortValue(getCellSortValue(leftCell)); - var rightComparable = getComparableSortValue(getCellSortValue(rightCell)); - return compareSortValues(leftComparable, rightComparable) * multiplier; - }); - - rows.forEach(function (row) { - tbody.appendChild(row); - }); - } - - function applyTableSort(table) { - var state = getSortableTableState(table); - if (state.columnIndex === null || state.columnIndex === undefined) { - return; - } - sortTable(table, state.columnIndex, state.direction); - updateSortableHeaderState(table); - } - - function initSortableTables() { - var tables = Array.prototype.slice.call(document.querySelectorAll('table')); - tables.forEach(function (table) { - var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null; - if (!headerRow) { - return; - } - - Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) { - if (!isSortableTableColumn(table, index)) { - return; - } - - ensureSortableHeaderIndicator(headerCell); - - headerCell.addEventListener('click', function () { - var state = getSortableTableState(table); - var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc'; - state.columnIndex = index; - state.direction = nextDirection; - sortTable(table, index, nextDirection); - updateSortableHeaderState(table); - }); - - headerCell.addEventListener('keydown', function (event) { - if (event.key === 'Enter' || event.key === ' ') { - event.preventDefault(); - headerCell.click(); - } - }); - }); - - updateSortableHeaderState(table); - }); - } - - window.applyTableSort = applyTableSort; - window.initSortableTables = initSortableTables; -}()); \ No newline at end of file diff --git a/src/web/public/js/table/table-search.js b/src/web/public/js/table/table-search.js new file mode 100644 index 0000000..fd6fdb7 --- /dev/null +++ b/src/web/public/js/table/table-search.js @@ -0,0 +1,170 @@ +(function () { + function rebindTableContainer(container) { + if (!container) { + return; + } + + if (typeof window.initSortableTables === 'function') { + window.initSortableTables(container); + } + + if (typeof window.initTableSearches === 'function') { + window.initTableSearches(container); + } + + if (typeof window.initLocalDateTimes === 'function') { + window.initLocalDateTimes(container); + } + } + + function focusSearchInput(input) { + if (!input || !input.focus) { + return; + } + + input.focus(); + + if (typeof input.setSelectionRange === 'function') { + var valueLength = String(input.value || '').length; + try { + input.setSelectionRange(valueLength, valueLength); + } catch (_error) { + // Ignore selection failures on unsupported input types. + } + } + } + + function replaceTableResults(container, responseDocument) { + if (!container || !responseDocument || !responseDocument.querySelector) { + return false; + } + + var currentTable = container.querySelector('[data-table-searchable]'); + var nextContainer = responseDocument.querySelector('[data-table-search-container]') || responseDocument.querySelector('.card'); + var nextTable = nextContainer ? nextContainer.querySelector('[data-table-searchable]') : null; + var currentFooter = container.querySelector('.card-footer'); + var nextFooter = nextContainer ? nextContainer.querySelector('.card-footer') : null; + + if (!currentTable || !nextTable) { + return false; + } + + var currentBody = currentTable.tBodies && currentTable.tBodies[0] ? currentTable.tBodies[0] : null; + var nextBody = nextTable.tBodies && nextTable.tBodies[0] ? nextTable.tBodies[0] : null; + + if (!currentBody || !nextBody) { + return false; + } + + currentBody.outerHTML = nextBody.outerHTML; + + if (currentFooter && nextFooter) { + currentFooter.outerHTML = nextFooter.outerHTML; + } else if (currentFooter && !nextFooter) { + currentFooter.parentNode.removeChild(currentFooter); + } else if (!currentFooter && nextFooter) { + currentTable.parentNode.appendChild(nextFooter.cloneNode(true)); + } + + return true; + } + + function initTableSearches(root) { + var scope = root && root.querySelectorAll ? root : document; + + Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) { + if (input.getAttribute('data-table-search-bound') === 'true') { + return; + } + + input.setAttribute('data-table-search-bound', 'true'); + + var searchParam = String(input.getAttribute('data-table-search-param') || 'search').trim() || 'search'; + var currentUrl = new URL(window.location.href); + var pendingSearchTimer = null; + var requestSequence = 0; + var container = input.closest('[data-table-search-container]') || input.closest('.card') || null; + + input.value = String(currentUrl.searchParams.get(searchParam) || '').trim(); + + function updateSearch() { + var query = String(input.value || '').trim(); + var nextUrl = new URL(window.location.href); + + if (query) { + nextUrl.searchParams.set(searchParam, query); + } else { + nextUrl.searchParams.delete(searchParam); + } + nextUrl.searchParams.delete('page'); + + if (nextUrl.toString() === currentUrl.toString()) { + return; + } + + requestSequence += 1; + var sequenceId = requestSequence; + + fetch(nextUrl.toString(), { + method: 'GET', + headers: { + 'Accept': 'text/html, application/xhtml+xml', + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'same-origin', + cache: 'no-store' + }).then(function (response) { + if (!response.ok) { + throw new Error('Unable to load search results.'); + } + return response.text(); + }).then(function (text) { + if (sequenceId !== requestSequence) { + return; + } + + var responseDocument = new DOMParser().parseFromString(text || '', 'text/html'); + + if (window.history && window.history.replaceState) { + window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash); + } + + if (replaceTableResults(container, responseDocument)) { + currentUrl = nextUrl; + rebindTableContainer(container || document); + focusSearchInput(input); + } + }).catch(function () { + window.location.assign(nextUrl.toString()); + }); + } + + function scheduleSearchUpdate() { + if (pendingSearchTimer) { + window.clearTimeout(pendingSearchTimer); + } + pendingSearchTimer = window.setTimeout(function () { + pendingSearchTimer = null; + updateSearch(); + }, 300); + } + + input.addEventListener('input', scheduleSearchUpdate); + input.addEventListener('search', scheduleSearchUpdate); + input.addEventListener('change', updateSearch); + input.addEventListener('keydown', function (event) { + if (event.key === 'Enter') { + event.preventDefault(); + if (pendingSearchTimer) { + window.clearTimeout(pendingSearchTimer); + pendingSearchTimer = null; + } + updateSearch(); + } + }); + }); + } + + window.initTableSearches = initTableSearches; + window.replaceTableResults = replaceTableResults; +}()); \ No newline at end of file diff --git a/src/web/public/js/table/table-sort.js b/src/web/public/js/table/table-sort.js new file mode 100644 index 0000000..aa5196d --- /dev/null +++ b/src/web/public/js/table/table-sort.js @@ -0,0 +1,197 @@ +(function () { + function getTableSortState(table) { + if (!table._tableSortState) { + table._tableSortState = { + sortKey: '', + direction: 'asc' + }; + } + return table._tableSortState; + } + + function ensureSortableHeaderIndicator(headerCell) { + var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator'); + if (indicator) { + return indicator; + } + + indicator = document.createElement('i'); + indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1'; + indicator.setAttribute('aria-hidden', 'true'); + headerCell.appendChild(indicator); + return indicator; + } + + function updateSortableHeaderState(table) { + var state = getTableSortState(table); + var currentUrl = new URL(window.location.href); + var activeSortKey = String(currentUrl.searchParams.get('sort') || '').trim(); + var activeDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; + var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : []; + + state.sortKey = activeSortKey; + state.direction = activeDirection; + + headerCells.forEach(function (headerCell) { + if (!headerCell) { + return; + } + + var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim(); + var sortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false'; + + headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable'); + headerCell.removeAttribute('aria-sort'); + + if (!sortable) { + headerCell.classList.add('unsortable'); + var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator'); + if (hiddenIndicator) { + hiddenIndicator.style.display = 'none'; + } + return; + } + + var indicator = ensureSortableHeaderIndicator(headerCell); + indicator.style.display = ''; + indicator.className = 'table-sort-indicator bi ms-1'; + headerCell.classList.add('sortable'); + headerCell.setAttribute('role', 'button'); + headerCell.setAttribute('tabindex', '0'); + + if (sortKey === activeSortKey) { + headerCell.classList.add(activeDirection === 'desc' ? 'sort-desc' : 'sort-asc'); + headerCell.setAttribute('aria-sort', activeDirection === 'desc' ? 'descending' : 'ascending'); + if (activeDirection === 'desc') { + indicator.classList.add('bi-caret-down-fill'); + } else { + indicator.classList.add('bi-caret-up-fill'); + } + return; + } + + headerCell.setAttribute('aria-sort', 'none'); + indicator.classList.add('bi-arrow-down-up'); + }); + } + + function buildSortedUrl(sortKey) { + var currentUrl = new URL(window.location.href); + var nextUrl = new URL(window.location.href); + var currentSortKey = String(currentUrl.searchParams.get('sort') || '').trim(); + var currentDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; + + if (currentSortKey === sortKey) { + nextUrl.searchParams.set('direction', currentDirection === 'asc' ? 'desc' : 'asc'); + } else { + nextUrl.searchParams.set('sort', sortKey); + nextUrl.searchParams.set('direction', 'asc'); + } + + nextUrl.searchParams.delete('page'); + return nextUrl; + } + + function updateUrlState(nextUrl) { + if (window.history && window.history.replaceState) { + window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash); + } + } + + function refreshSortedTable(table, nextUrl, requestSequence, sequenceId) { + var container = table ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) : null; + + fetch(nextUrl.toString(), { + method: 'GET', + headers: { + 'Accept': 'text/html, application/xhtml+xml', + 'X-Requested-With': 'XMLHttpRequest' + }, + credentials: 'same-origin', + cache: 'no-store' + }).then(function (response) { + if (!response.ok) { + throw new Error('Unable to load sorted results.'); + } + return response.text(); + }).then(function (text) { + if (sequenceId !== requestSequence.value) { + return; + } + + var responseDocument = new DOMParser().parseFromString(text || '', 'text/html'); + updateUrlState(nextUrl); + + if (window.replaceTableResults && replaceTableResults(container, responseDocument)) { + if (typeof window.initSortableTables === 'function') { + window.initSortableTables(container || document); + } + if (typeof window.initTableSearches === 'function') { + window.initTableSearches(container || document); + } + if (typeof window.initLocalDateTimes === 'function') { + window.initLocalDateTimes(container || document); + } + } + }).catch(function () { + window.location.assign(nextUrl.toString()); + }); + } + + function initSortableTables(root) { + var scope = root && root.querySelectorAll ? root : document; + + Array.prototype.forEach.call(scope.querySelectorAll('table[data-table-searchable]'), function (table) { + var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null; + if (!headerRow) { + return; + } + + if (table.getAttribute('data-table-sort-bound') === 'true') { + updateSortableHeaderState(table); + return; + } + + table.setAttribute('data-table-sort-bound', 'true'); + var requestSequence = { value: 0 }; + + Array.prototype.forEach.call(headerRow.cells, function (headerCell) { + if (!headerCell) { + return; + } + + var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim(); + var isSortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false'; + if (!isSortable) { + return; + } + + if (headerCell.getAttribute('data-table-sort-bound') === 'true') { + return; + } + + headerCell.setAttribute('data-table-sort-bound', 'true'); + ensureSortableHeaderIndicator(headerCell); + + headerCell.addEventListener('click', function () { + var nextUrl = buildSortedUrl(sortKey); + requestSequence.value += 1; + var sequenceId = requestSequence.value; + refreshSortedTable(table, nextUrl, requestSequence, sequenceId); + }); + + headerCell.addEventListener('keydown', function (event) { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + headerCell.click(); + } + }); + }); + + updateSortableHeaderState(table); + }); + } + + window.initSortableTables = initSortableTables; + initSortableTables(); +}()); \ No newline at end of file diff --git a/src/web/public/js/theme-sidebar.js b/src/web/public/js/theme-sidebar.js deleted file mode 100644 index f4704a5..0000000 --- a/src/web/public/js/theme-sidebar.js +++ /dev/null @@ -1,118 +0,0 @@ -(function () { - var THEME_STORAGE_KEY = 'web-theme'; - - function getPreferredTheme() { - if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) { - return 'dark'; - } - return 'light'; - } - - function getStoredTheme() { - try { - var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); - if (storedTheme === 'dark' || storedTheme === 'light') { - return storedTheme; - } - } catch (error) { - return null; - } - return null; - } - - function setStoredTheme(theme) { - try { - window.localStorage.setItem(THEME_STORAGE_KEY, theme); - } catch (error) { - return; - } - } - - function applyTheme(theme) { - var normalizedTheme = theme === 'dark' ? 'dark' : 'light'; - var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark'; - var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode'; - - document.documentElement.dataset.bsTheme = normalizedTheme; - document.documentElement.style.colorScheme = normalizedTheme; - - Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) { - var icon = toggleButton.querySelector('.theme-toggle__icon'); - toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false'); - toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase()); - if (icon) { - icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun'); - icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon'); - } - }); - - return normalizedTheme; - } - - function initThemeToggle() { - var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]')); - var storedTheme = getStoredTheme(); - var theme = storedTheme || getPreferredTheme(); - - applyTheme(theme); - - if (!toggleButtons.length) { - return; - } - - Array.prototype.forEach.call(toggleButtons, function (toggleButton) { - toggleButton.addEventListener('click', function () { - var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark'; - setStoredTheme(nextTheme); - applyTheme(nextTheme); - }); - }); - } - - function initSidebarToggle() { - var body = document.body; - var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]')); - var backdrop = document.querySelector('[data-sidebar-backdrop]'); - - if (!toggleButtons.length || !backdrop) { - return; - } - - function setSidebarOpen(isOpen) { - body.classList.toggle('is-sidebar-open', isOpen); - Array.prototype.forEach.call(toggleButtons, function (toggleButton) { - toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false'); - }); - } - - function toggleSidebar() { - setSidebarOpen(!body.classList.contains('is-sidebar-open')); - } - - Array.prototype.forEach.call(toggleButtons, function (toggleButton) { - toggleButton.addEventListener('click', function () { - toggleSidebar(); - }); - }); - - backdrop.addEventListener('click', function () { - setSidebarOpen(false); - }); - - document.addEventListener('keydown', function (event) { - if (event.key === 'Escape') { - setSidebarOpen(false); - } - }); - } - - window.getPreferredTheme = getPreferredTheme; - window.getStoredTheme = getStoredTheme; - window.setStoredTheme = setStoredTheme; - window.applyTheme = applyTheme; - window.initThemeToggle = initThemeToggle; - window.initSidebarToggle = initSidebarToggle; - - initThemeToggle(); - initSidebarToggle(); -}()); \ No newline at end of file diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index a1a593c..602dead 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -208,10 +208,13 @@ module.exports = function registerAdminContentRoutes(app, deps) { app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderSlidesPage({ slides: data.slides || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); @@ -413,10 +416,13 @@ module.exports = function registerAdminContentRoutes(app, deps) { app.get('/templates', requirePermission('templates.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderTemplatesPage({ templates: data.templates || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); @@ -560,10 +566,13 @@ module.exports = function registerAdminContentRoutes(app, deps) { app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderCanvasSizesPage({ canvasSizes: data.canvasSizes || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/admin/data-sources.js b/src/web/routes/admin/data-sources.js index f232ae5..5e6d6a0 100644 --- a/src/web/routes/admin/data-sources.js +++ b/src/web/routes/admin/data-sources.js @@ -199,7 +199,10 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) { app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); const apiSources = (data.apiSources || []).map(function (apiSource) { return Object.assign({}, apiSource, { intervalLabel: apiSource.update_interval_unit === 'seconds' @@ -211,7 +214,7 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) { }); res.send(pages.renderApiSourcesPage({ apiSources: apiSources, - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'API sources', 'API source pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'API sources', 'API source pages') }, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate)); } catch (error) { next(error); @@ -381,10 +384,13 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) { app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderRssFeedsPage({ rssFeeds: data.rssFeeds || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'RSS feeds', 'RSS feed pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/admin/manage.js b/src/web/routes/admin/manage.js index 6a22de8..9f82970 100644 --- a/src/web/routes/admin/manage.js +++ b/src/web/routes/admin/manage.js @@ -36,12 +36,15 @@ module.exports = function registerAdminManageRoutes(app, deps) { const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout') ? req.body.blackout : req.query.blackout; + const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused') + ? req.body.paused + : req.query.paused; if (!command) { return res.status(400).json({ error: 'Command is required' }); } - if (command !== 'reload' && command !== 'blackout') { + if (command !== 'reload' && command !== 'blackout' && command !== 'pause') { return res.status(400).json({ error: 'Unsupported command' }); } @@ -56,7 +59,12 @@ module.exports = function registerAdminManageRoutes(app, deps) { command: 'blackout', blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false } - : 'reload'; + : command === 'pause' + ? { + command: 'pause', + paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false + } + : 'reload'; const sentCount = await notifyPlayerScreens(slugs, payload); await broadcastDashboardState(); @@ -65,7 +73,8 @@ module.exports = function registerAdminManageRoutes(app, deps) { ok: true, command: command, sent: sentCount, - blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined + blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined, + paused: command === 'pause' ? Boolean(payload.paused) : undefined }); } catch (error) { next(error); diff --git a/src/web/routes/admin/rbac.js b/src/web/routes/admin/rbac.js index 766e41c..7b0fd64 100644 --- a/src/web/routes/admin/rbac.js +++ b/src/web/routes/admin/rbac.js @@ -162,10 +162,13 @@ module.exports = function registerAdminRbacRoutes(app, deps) { app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderRbacPage({ roles: data.roles || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'roles', 'Role pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/admin/users.js b/src/web/routes/admin/users.js index 9a4ed2e..412b9b7 100644 --- a/src/web/routes/admin/users.js +++ b/src/web/routes/admin/users.js @@ -57,7 +57,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) { app.get('/users', requirePermission('users.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); const mappedUsers = (data.users || []).map(function (user) { return Object.assign({}, user, { isCurrentUser: Number(user.id) === Number(req.currentUser.id), @@ -68,7 +71,7 @@ module.exports = function registerAdminUsersRoutes(app, deps) { }); res.send(pages.renderUsersPage({ users: mappedUsers, - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'users', 'Users pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'Users pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/settings/background-tasks-page.js b/src/web/routes/settings/background-tasks-page.js index a352c90..1939b20 100644 --- a/src/web/routes/settings/background-tasks-page.js +++ b/src/web/routes/settings/background-tasks-page.js @@ -1,10 +1,11 @@ const { renderView } = require('../../view'); const { hasAnyPermission } = require('../../../rbac'); -const { buildPaginationPages } = require('../../lib/pagination'); +const { buildPagination, buildQueryString } = require('../../lib/pagination'); +const { parsePageNumber, normalizeSortDirection, sortRows, createSearchMatcher } = require('../../lib/list-query'); const TASKS_PER_PAGE = 10; -const QUEUE_PAGE_URL = '/settings/background-tasks'; -const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled'; +const QUEUE_PAGE_URL = '/settings/tasks-background'; +const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled'; function formatIntervalLabel(intervalMs) { const value = Math.max(1, Number(intervalMs) || 0); @@ -19,11 +20,6 @@ function formatIntervalLabel(intervalMs) { return `${value} ms`; } -function parsePageNumber(value) { - const pageNumber = Math.floor(Number(value) || 1); - return Math.max(1, pageNumber); -} - function normalizeSourceFilter(sourceType, sourceId) { const normalizedSourceType = String(sourceType || '').trim(); const normalizedSourceId = Math.floor(Number(sourceId) || 0); @@ -38,48 +34,6 @@ function normalizeSourceFilter(sourceType, sourceId) { }; } -function buildQueryString(query) { - const searchParams = new URLSearchParams(); - ['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) { - const value = query && query[key]; - if (value === undefined || value === null || value === '') { - return; - } - searchParams.set(key, String(value)); - }); - - const queryString = searchParams.toString(); - return queryString ? `?${queryString}` : ''; -} - -function buildPagination(totalItems, currentPage, pageParam, queryState) { - const normalizedPageParam = String(pageParam || 'page').trim() || 'page'; - const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE)); - const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages); - const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE; - const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE); - const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7); - - const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 }); - const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 }); - - return { - currentPage: safeCurrentPage, - totalPages: totalPages, - totalItems: totalItems, - hasMultiplePages: totalPages > 1, - startItem: totalItems === 0 ? 0 : startIndex + 1, - endItem: endIndex, - hasPrevious: safeCurrentPage > 1, - hasNext: safeCurrentPage < totalPages, - previousUrl: buildQueryString(previousQuery), - nextUrl: buildQueryString(nextQuery), - pages: pages, - pageSize: TASKS_PER_PAGE, - pageParam: normalizedPageParam - }; -} - function taskMatchesFilters(task, sourceFilter) { if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) { return true; @@ -89,6 +43,8 @@ function taskMatchesFilters(task, sourceFilter) { return String(metadata.sourceType || '').trim() === sourceFilter.sourceType && Number(metadata.sourceId || 0) === sourceFilter.sourceId; } +const taskMatchesSearch = createSearchMatcher; + function formatStatusLabel(status) { const normalized = String(status || '').trim().toLowerCase(); if (!normalized) { @@ -133,25 +89,61 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) { function buildQueuePageViewModel(data, message, currentUser) { const tasks = (data && data.tasks) || []; - const recurringTasks = (data && data.recurringTasks) || []; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 }; const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']); + const sort = String(data && data.sort || '').trim(); + const direction = normalizeSortDirection(data && data.direction); const queryState = { page: parsePageNumber(data && data.page), - recurringPage: parsePageNumber(data && data.recurringPage), sourceType: '', - sourceId: '' + sourceId: '', + search: String(data && data.search || '').trim(), + sort: sort, + direction: direction }; const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId); queryState.sourceType = sourceFilter.sourceType; queryState.sourceId = sourceFilter.sourceId; const filteredTasks = tasks.filter(function (task) { - return taskMatchesFilters(task, sourceFilter); + return taskMatchesFilters(task, sourceFilter) && taskMatchesSearch(queryState.search, [ + 'title', + 'key', + 'category', + 'status', + 'errorMessage', + function (item) { return item && item.metadata && item.metadata.sourceName; }, + function (item) { return item && item.metadata && item.metadata.sourceType; }, + function (item) { return item && item.metadata && item.metadata.sourceId; } + ])(task); }); - const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState); - const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState); - const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE); - const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE); + const sortedTasks = sortRows(filteredTasks, function (task) { + if (sort === 'task') { + return task && task.title; + } + if (sort === 'category') { + return task && task.category; + } + if (sort === 'status') { + return task && task.status; + } + if (sort === 'queued') { + return task && task.createdAt; + } + if (sort === 'started') { + return task && task.startedAt; + } + if (sort === 'finished') { + return task && task.finishedAt; + } + if (sort === 'source') { + return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) { + return String(value || '').trim(); + }).join(' '); + } + return task && task.createdAt; + }, direction); + const pagination = buildPagination(sortedTasks.length, queryState.page, 'page', queryState, TASKS_PER_PAGE, 'tasks', 'Background task pages'); + const visibleTasks = sortedTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE); const statusCards = [ { status: 'queued', @@ -199,16 +191,14 @@ function buildQueuePageViewModel(data, message, currentUser) { message: message, currentUser: currentUser || null, scripts: ['js/settings/background-tasks.js'], - stateVersion: data && data.stateVersion ? String(data.stateVersion) : '', backgroundTasksMenuOpen: true, tasks: visibleTasksWithSources, summary: summary, statusCards: statusCards, activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '', - clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }), + clearFiltersUrl: QUEUE_PAGE_URL, hasActiveFilters: hasActiveFilters, pagination: pagination, - recurringPagination: recurringPagination, canManage: canManage, canClearFinished: tasks.some(function (task) { return task.status !== 'queued' && task.status !== 'running'; @@ -220,6 +210,47 @@ function buildScheduledPageViewModel(data, message, currentUser) { const recurringTasks = (data && data.recurringTasks) || []; const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0, scheduledCount: 0 }; const canManage = hasAnyPermission(currentUser, ['scheduled-tasks.allow']); + const search = String(data && data.search || '').trim(); + const sort = String(data && data.sort || '').trim(); + const direction = normalizeSortDirection(data && data.direction); + const filteredRecurringTasks = recurringTasks.filter(function (task) { + return taskMatchesSearch(search, [ + 'title', + 'intervalLabel', + 'nextRunAt', + 'lastRunAt', + 'lastStatus', + 'lastError', + function (item) { return item && item.metadata && item.metadata.sourceName; }, + function (item) { return item && item.metadata && item.metadata.sourceType; }, + function (item) { return item && item.metadata && item.metadata.sourceId; } + ])(task); + }); + const sortedRecurringTasks = sortRows(filteredRecurringTasks, function (task) { + if (sort === 'task') { + return task && task.title; + } + if (sort === 'interval') { + return task && task.intervalMs; + } + if (sort === 'next_run') { + return task && task.nextRunAt; + } + if (sort === 'last_run') { + return task && task.lastRunAt; + } + if (sort === 'last_result') { + return [task && task.lastStatus, task && task.lastError].map(function (value) { + return String(value || '').trim(); + }).join(' '); + } + if (sort === 'source') { + return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) { + return String(value || '').trim(); + }).join(' '); + } + return task && task.nextRunAt; + }, direction); return renderView('settings/tasks-scheduled/index', { title: 'Scheduled tasks', @@ -227,17 +258,16 @@ function buildScheduledPageViewModel(data, message, currentUser) { message: message, currentUser: currentUser || null, scripts: ['js/settings/background-tasks.js'], - stateVersion: data && data.stateVersion ? String(data.stateVersion) : '', backgroundTasksMenuOpen: true, - recurringTasks: recurringTasks.map(function (task) { + recurringTasks: sortedRecurringTasks.map(function (task) { return Object.assign({}, task, { intervalLabel: formatIntervalLabel(task.intervalMs) }); }), summary: summary, canManage: canManage, - scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0), - recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) }) + scheduledCount: Number(summary.scheduledCount || filteredRecurringTasks.length || 0), + recurringPagination: buildPagination(sortedRecurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage), search: search, sort: sort, direction: direction }) }); } diff --git a/src/web/routes/settings/background-tasks.js b/src/web/routes/settings/background-tasks.js index 1d58ed8..b9c2479 100644 --- a/src/web/routes/settings/background-tasks.js +++ b/src/web/routes/settings/background-tasks.js @@ -42,65 +42,18 @@ module.exports = function registerAdminSettingsRoutes(app, deps) { throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.'); } - function buildQueueVersion(tasks, recurringTasks, summary) { - return [ - 'q:' + Number(summary && summary.counts && summary.counts.queued || 0), - 'r:' + Number(summary && summary.counts && summary.counts.running || 0), - 'f:' + Number(summary && summary.counts && summary.counts.failed || 0), - 'c:' + Number(summary && summary.counts && summary.counts.completed || 0), - 'a:' + Number(summary && summary.activeCount || 0), - 't:' + Number(summary && summary.total || 0), - 'tasks:' + (tasks || []).map(function (task) { - return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':'); - }).join('|'), - 'recurring:' + (recurringTasks || []).map(function (task) { - return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':'); - }).join('|') - ].join('~'); - } - - app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) { + app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) { const tasks = backgroundTaskQueue.listTasks(); const recurringTasks = backgroundTaskQueue.listRecurringTasks(); const summary = backgroundTaskQueue.getSummary(); - const version = buildQueueVersion(tasks, recurringTasks, summary); - res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser)); + res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser)); }); - app.get('/settings/background-tasks/scheduled', requireScheduledSettingsAccess(), function (req, res) { + app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) { const tasks = backgroundTaskQueue.listTasks(); const recurringTasks = backgroundTaskQueue.listRecurringTasks(); const summary = backgroundTaskQueue.getSummary(); - const version = buildQueueVersion(tasks, recurringTasks, summary); - res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser)); - }); - - app.get('/settings/background-tasks/state', function (req, res, next) { - if (!req.currentUser) { - return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); - } - - if (!hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow', 'scheduled-tasks.read', 'scheduled-tasks.allow'])) { - const error = new Error('You do not have permission to access this area.'); - error.statusCode = 403; - error.expose = true; - return next(error); - } - - return next(); - }, function (req, res) { - const tasks = backgroundTaskQueue.listTasks(); - const recurringTasks = backgroundTaskQueue.listRecurringTasks(); - const summary = backgroundTaskQueue.getSummary(); - const version = buildQueueVersion(tasks, recurringTasks, summary); - - res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private'); - res.json({ - version: version, - summary: summary, - taskCount: tasks.length, - recurringCount: recurringTasks.length - }); + res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser)); }); function requireSettingsManageAccess() { @@ -120,22 +73,7 @@ module.exports = function registerAdminSettingsRoutes(app, deps) { }; } - app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) { - const removedCount = backgroundTaskQueue.clearFinishedTasks(); - res.redirect('/settings/background-tasks?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.')); - }); - - app.post('/settings/background-tasks/:id/cancel', requireSettingsManageAccess(), function (req, res) { - const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id)); - res.redirect('/settings/background-tasks?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.')); - }); - - app.post('/settings/background-tasks/:id/retry', requireSettingsManageAccess(), function (req, res) { - const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id)); - res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.')); - }); - - app.post('/settings/background-tasks/recurring/:key/run', function (req, res, next) { + function requireRecurringTaskAccess(req, res, next) { if (!req.currentUser) { return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.')); } @@ -148,8 +86,25 @@ module.exports = function registerAdminSettingsRoutes(app, deps) { } return next(); - }, function (req, res) { + } + + app.post('/settings/tasks-background/clear-finished', requireSettingsManageAccess(), function (req, res) { + const removedCount = backgroundTaskQueue.clearFinishedTasks(); + res.redirect('/settings/tasks-background?message=' + encodeURIComponent(removedCount ? 'Cleared ' + removedCount + ' finished task' + (removedCount === 1 ? '' : 's') + '.' : 'No finished tasks to clear.')); + }); + + app.post('/settings/tasks-background/:id/cancel', requireSettingsManageAccess(), function (req, res) { + const canceled = backgroundTaskQueue.cancelTask(Number(req.params.id)); + res.redirect('/settings/tasks-background?message=' + encodeURIComponent(canceled ? 'Task canceled.' : 'Unable to cancel that task.')); + }); + + app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) { + const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id)); + res.redirect('/settings/tasks-background?message=' + encodeURIComponent(retriedTask ? 'Task requeued.' : 'Unable to retry that task.')); + }); + + app.post('/settings/tasks-scheduled/recurring/:key/run', requireRecurringTaskAccess, function (req, res) { const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim()); - res.redirect('/settings/background-tasks/scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.')); + res.redirect('/settings/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.')); }); }; diff --git a/src/web/routes/signage/canvas-sizes/routes.js b/src/web/routes/signage/canvas-sizes/routes.js index 9ef7f93..af15427 100644 --- a/src/web/routes/signage/canvas-sizes/routes.js +++ b/src/web/routes/signage/canvas-sizes/routes.js @@ -10,10 +10,13 @@ module.exports = function registerCanvasSizeRoutes(app, deps) { app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderCanvasSizesPage({ canvasSizes: data.canvasSizes || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'canvas sizes', 'Canvas size pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/signage/clients/routes.js b/src/web/routes/signage/clients/routes.js index fb91739..e28b41a 100644 --- a/src/web/routes/signage/clients/routes.js +++ b/src/web/routes/signage/clients/routes.js @@ -1,22 +1,65 @@ module.exports = function registerClientsRoutes(app, deps) { const pool = deps.pool; + const common = deps.common; const pages = deps.pages; const buildDashboardState = deps.buildDashboardState; const { buildPagination } = require('../../../lib/pagination'); + const { sortRows, createSearchMatcher } = require('../../../lib/list-query'); const requirePermission = deps.requirePermission; const LIST_PAGE_SIZE = 10; + function sortClients(clients, sortKey, sortDirection) { + const normalizedSortKey = String(sortKey || '').trim(); + const normalizedDirection = String(sortDirection || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc'; + const accessors = { + client: function (client) { return String(client && (client.client_name || client.name || client.clientId) || '').trim(); }, + screen: function (client) { return String(client && (client.screen_name || client.screen_slug) || '').trim(); }, + slide: function (client) { return String(client && client.currentSlideTitle || '').trim(); }, + ip: function (client) { return String(client && client.clientIp || '').trim(); }, + viewport: function (client) { + const viewport = client && client.viewport; + if (!viewport || !viewport.width || !viewport.height) { + return ''; + } + return `${Number(viewport.width) || 0}x${Number(viewport.height) || 0}`; + }, + connected: function (client) { return String(client && (client.connectedAt || client.lastSeenAt) || '').trim(); } + }; + + if (!accessors[normalizedSortKey]) { + return Array.isArray(clients) ? clients.slice() : []; + } + + return sortRows(clients, function (client) { + return accessors[normalizedSortKey](client); + }, normalizedDirection); + } + app.get('/clients', requirePermission('clients.read'), async function (req, res, next) { try { const data = await buildDashboardState(pool); const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const totalItems = Array.isArray(data.clients) ? data.clients.length : 0; + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const matchesSearch = createSearchMatcher(search, [ + 'name', + 'client_name', + 'clientId', + 'deviceId', + 'slug', + 'screen_slug', + 'ipAddress', + 'status' + ]); + const filteredClients = (data.clients || []).filter(matchesSearch); + const sortedClients = sortClients(filteredClients, sort, direction); const startIndex = (page - 1) * LIST_PAGE_SIZE; - const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE); + const clients = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE); res.send(pages.renderConnectedClientsPage({ clients: clients, - pagination: buildPagination(totalItems, page, 'page', {}, LIST_PAGE_SIZE, 'clients', 'Client pages') + pagination: buildPagination(sortedClients.length, page, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'clients', 'Client pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/signage/playlists/routes.js b/src/web/routes/signage/playlists/routes.js index c69a140..a73ebc2 100644 --- a/src/web/routes/signage/playlists/routes.js +++ b/src/web/routes/signage/playlists/routes.js @@ -17,6 +17,9 @@ module.exports = function registerPlaylistsRoutes(app, deps) { app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); if (req.query.edit) { const data = await common.fetchAdminData(pool); const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit)); @@ -25,10 +28,10 @@ module.exports = function registerPlaylistsRoutes(app, deps) { } return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser)); } - const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE); + const data = await common.fetchPlaylistsPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderPlaylistsPage({ playlists: data.playlists || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'playlists', 'Playlist pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'playlists', 'Playlist pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/signage/screens/routes.js b/src/web/routes/signage/screens/routes.js index 56f20be..83b827c 100644 --- a/src/web/routes/signage/screens/routes.js +++ b/src/web/routes/signage/screens/routes.js @@ -15,10 +15,39 @@ module.exports = function registerScreensRoutes(app, deps) { }; } + function applyConnectionCounts(screens, dashboardScreens) { + const countsById = new Map(); + const countsBySlug = new Map(); + + (dashboardScreens || []).forEach(function (screen) { + const count = Number(screen && screen.player_connection_count || 0); + const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : ''; + const slug = String(screen && screen.slug || '').trim(); + + if (id) { + countsById.set(id, count); + } + if (slug) { + countsBySlug.set(slug, count); + } + }); + + return (screens || []).map(function (screen) { + const slug = String(screen && screen.slug || '').trim(); + const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : ''; + const playerConnectionCount = countsBySlug.has(slug) + ? countsBySlug.get(slug) + : countsById.get(id) || 0; + + return Object.assign({}, screen, { + player_connection_count: playerConnectionCount + }); + }); + } + app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) { try { if (req.query.edit) { - const data = await buildDashboardState(pool); const screen = await common.fetchScreenById(pool, Number(req.query.edit)); if (!screen) { return res.status(404).send('Screen not found'); @@ -27,10 +56,14 @@ module.exports = function registerScreensRoutes(app, deps) { return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser)); } const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const dashboardState = await buildDashboardState(pool); + const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderScreensPage({ - screens: data.screens || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'screens', 'Screen pages') + screens: applyConnectionCounts(data.screens || [], dashboardState.screens || []), + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'screens', 'Screen pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/signage/slides/routes.js b/src/web/routes/signage/slides/routes.js index aa52f43..1f6258f 100644 --- a/src/web/routes/signage/slides/routes.js +++ b/src/web/routes/signage/slides/routes.js @@ -10,10 +10,13 @@ module.exports = function registerSlidesRoutes(app, deps) { app.get('/slides', requirePermission('slides.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderSlidesPage({ slides: data.slides || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/routes/signage/templates/routes.js b/src/web/routes/signage/templates/routes.js index df74118..6074aa7 100644 --- a/src/web/routes/signage/templates/routes.js +++ b/src/web/routes/signage/templates/routes.js @@ -10,10 +10,13 @@ module.exports = function registerTemplatesRoutes(app, deps) { app.get('/templates', requirePermission('templates.read'), async function (req, res, next) { try { const page = Math.max(1, Math.floor(Number(req.query.page) || 1)); - const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE); + const search = common.getSearchQuery(req); + const sort = common.getSortQuery(req); + const direction = common.getSortDirectionQuery(req); + const data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction); res.send(pages.renderTemplatesPage({ templates: data.templates || [], - pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages') + pagination: buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages') }, req.query.message ? String(req.query.message) : '', req.currentUser)); } catch (error) { next(error); diff --git a/src/web/view.js b/src/web/view.js index 991d292..d115e81 100644 --- a/src/web/view.js +++ b/src/web/view.js @@ -110,7 +110,7 @@ Handlebars.registerHelper('saveActionButtons', function (options) { }); Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8')); -Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8')); +Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table', 'table-pagination.hbs'), 'utf8')); function resolveTemplatePath(relativePath) { const firstSegment = String(relativePath || '').split(/[\\/]/)[0]; diff --git a/src/web/views/data-sources/api-sources/list.hbs b/src/web/views/data-sources/api-sources/list.hbs index c0f3870..2b31e91 100644 --- a/src/web/views/data-sources/api-sources/list.hbs +++ b/src/web/views/data-sources/api-sources/list.hbs @@ -22,11 +22,11 @@ - - - - - + + + + + diff --git a/src/web/views/data-sources/rss-feeds/list.hbs b/src/web/views/data-sources/rss-feeds/list.hbs index 83970c2..ba89259 100644 --- a/src/web/views/data-sources/rss-feeds/list.hbs +++ b/src/web/views/data-sources/rss-feeds/list.hbs @@ -22,10 +22,10 @@
    NameAPI URLRefresh intervalLast pulledLast responseNameAPI URLRefresh intervalLast pulledLast response Actions
    - - - - + + + + diff --git a/src/web/views/settings/rbac/list.hbs b/src/web/views/settings/rbac/list.hbs index 2f7eec1..026a22c 100644 --- a/src/web/views/settings/rbac/list.hbs +++ b/src/web/views/settings/rbac/list.hbs @@ -24,10 +24,10 @@
    NameFeed URLRefresh intervalItems pulledNameFeed URLRefresh intervalItems pulled Actions
    - - - - + + + + diff --git a/src/web/views/settings/tasks-background/index.hbs b/src/web/views/settings/tasks-background/index.hbs index f6f6831..32ca035 100644 --- a/src/web/views/settings/tasks-background/index.hbs +++ b/src/web/views/settings/tasks-background/index.hbs @@ -1,5 +1,3 @@ - -
    RoleDescriptionUsersPermissionsRoleDescriptionUsersPermissions Actions
    - - - - - - - + + + + + + + @@ -129,27 +127,6 @@
    TaskCategoryStatusQueuedStartedFinishedSourceTaskCategoryStatusQueuedStartedFinishedSource
    - {{#if pagination.hasMultiplePages}} - - {{/if}} + {{> table-pagination pagination=pagination basePath="/settings/tasks-background"}} diff --git a/src/web/views/settings/tasks-scheduled/index.hbs b/src/web/views/settings/tasks-scheduled/index.hbs index dc94994..68822e2 100644 --- a/src/web/views/settings/tasks-scheduled/index.hbs +++ b/src/web/views/settings/tasks-scheduled/index.hbs @@ -1,5 +1,3 @@ - -