Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
de1469c29d | ||
|
|
42279d95aa | ||
|
|
9c5cabbd13 | ||
|
|
815c808d73 | ||
|
|
4afaeaafb1 | ||
|
|
ef9cdd2986 | ||
|
|
2176fb9042 | ||
|
|
6cb45d7839 | ||
|
|
eb1f33d82f | ||
|
|
07ec17ad91 | ||
|
|
b5fbf79af7 | ||
|
|
4ca4da473f |
@@ -0,0 +1,48 @@
|
||||
name: Bug report
|
||||
about: Report a problem with Pulse Signage
|
||||
labels:
|
||||
- bug
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Please provide enough detail to reproduce the problem.
|
||||
|
||||
- type: textarea
|
||||
id: steps
|
||||
attributes:
|
||||
label: Steps to reproduce
|
||||
description: List the actions that trigger the bug.
|
||||
placeholder: |
|
||||
1. Open the dashboard
|
||||
2. Change the playlist on a screen
|
||||
3. Save and wait for playback to refresh
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: expected
|
||||
attributes:
|
||||
label: Expected behavior
|
||||
description: What you expected to happen.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: actual
|
||||
attributes:
|
||||
label: Actual behavior
|
||||
description: What actually happened.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: environment
|
||||
attributes:
|
||||
label: Environment
|
||||
description: Include version, browser, player setup, and any relevant logs.
|
||||
placeholder: |
|
||||
Version:
|
||||
Browser:
|
||||
Player version:
|
||||
Logs:
|
||||
@@ -0,0 +1,5 @@
|
||||
blank_issues_enabled: false
|
||||
contact_links:
|
||||
- name: Documentation
|
||||
url: https://git.lzstealth.com/LZStealth/pulse-signage
|
||||
about: Check the project repository and docs before opening an issue.
|
||||
@@ -0,0 +1,37 @@
|
||||
name: Feature request
|
||||
about: Suggest an improvement or new capability
|
||||
labels:
|
||||
- enhancement
|
||||
body:
|
||||
- type: markdown
|
||||
attributes:
|
||||
value: |
|
||||
Share the problem you're trying to solve and the outcome you'd like.
|
||||
|
||||
- type: textarea
|
||||
id: problem
|
||||
attributes:
|
||||
label: Problem statement
|
||||
description: What limitation or workflow gap are you trying to address?
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: proposal
|
||||
attributes:
|
||||
label: Proposed solution
|
||||
description: Describe the change you would like to see.
|
||||
validations:
|
||||
required: true
|
||||
|
||||
- type: textarea
|
||||
id: alternatives
|
||||
attributes:
|
||||
label: Alternatives considered
|
||||
description: Any workarounds or other approaches you've considered.
|
||||
|
||||
- type: textarea
|
||||
id: context
|
||||
attributes:
|
||||
label: Additional context
|
||||
description: Mockups, screenshots, examples, or related links.
|
||||
+87
-7
@@ -6,6 +6,93 @@ All notable changes to this project will be documented in this file.
|
||||
|
||||
- No unreleased changes recorded yet.
|
||||
|
||||
## 1.5.16 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Background task handling was split into dedicated handler and scheduling modules, with startup data-source refreshes and unused-upload cleanup wired through the shared setup flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- The admin shell, shared theme, and toast presentation were refreshed to match the newer layout and notification styling.
|
||||
- Playlist scheduling now handles video-duration toggles and already-assigned slides more clearly in the picker and editor UI.
|
||||
- RBAC and settings views were updated to align with the revised admin layout and shared table behavior.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Template and slide thumbnail refreshes now run through the background task queue so template-wide thumbnail regeneration stays consistent.
|
||||
|
||||
## 1.5.15 - 2026-07-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Player slide transitions now start and pause video playback around the fade window so video regions stay aligned with the slide boundary.
|
||||
- Dashboard action cards now use a three-column grid on wider layouts.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Slide video duration probes now preserve sub-second precision instead of rounding to whole seconds.
|
||||
|
||||
## 1.5.14 - 2026-07-26
|
||||
|
||||
### Fixed
|
||||
|
||||
- Dashboard client action icons now render valid Bootstrap Icon classes without duplicating the `bi-` prefix.
|
||||
|
||||
## 1.5.13 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
|
||||
|
||||
### Changed
|
||||
|
||||
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
|
||||
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
|
||||
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
|
||||
|
||||
## 1.5.12 - 2026-07-26
|
||||
|
||||
### Changed
|
||||
|
||||
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
|
||||
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Template editing now blocks removal of regions that are still referenced by slides.
|
||||
|
||||
## 1.5.11 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
|
||||
|
||||
### Changed
|
||||
|
||||
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
|
||||
- Player rendering and related media handling now recognize the new video region implementation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
|
||||
|
||||
## 1.5.10 - 2026-07-26
|
||||
|
||||
### Added
|
||||
|
||||
- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view.
|
||||
- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow.
|
||||
|
||||
### Changed
|
||||
|
||||
- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate.
|
||||
- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation.
|
||||
|
||||
### Fixed
|
||||
|
||||
- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably.
|
||||
|
||||
## 1.5.9 - 2026-07-26
|
||||
|
||||
### Fixed
|
||||
@@ -16,13 +103,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
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.4",
|
||||
"version": "1.5.16",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.4",
|
||||
"version": "1.5.16",
|
||||
"dependencies": {
|
||||
"@sparticuz/chromium": "^137.0.0",
|
||||
"bootstrap-icons": "1.11.3",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "pulse-signage",
|
||||
"version": "1.5.9",
|
||||
"version": "1.5.16",
|
||||
"private": false,
|
||||
"description": "Pulse Signage application with MySQL and media storage",
|
||||
"repository": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
+62
-7
@@ -25,7 +25,7 @@ async function fetchAdminData(pool) {
|
||||
ORDER BY s.id DESC
|
||||
`);
|
||||
const [playlistSlides] = await pool.query(`
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||
@@ -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,15 +84,28 @@ 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,
|
||||
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count
|
||||
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
|
||||
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
|
||||
FROM slide_templates st
|
||||
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
|
||||
});
|
||||
@@ -80,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
|
||||
});
|
||||
@@ -94,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
|
||||
});
|
||||
|
||||
+14
-1
@@ -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
|
||||
});
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
+13
-1
@@ -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
|
||||
});
|
||||
|
||||
@@ -92,6 +92,18 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
||||
type: 'image',
|
||||
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||
};
|
||||
} else if (region.region_type === 'video') {
|
||||
const uploaded = filesByField[`region_video_${region.id}`];
|
||||
const existing = body[`existing_region_video_${region.id}`];
|
||||
const durationValue = body[`existing_region_video_duration_${region.id}`];
|
||||
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
|
||||
const parsedDuration = Number(durationValue || existingDuration || 0);
|
||||
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
|
||||
content[region.region_key] = {
|
||||
type: 'video',
|
||||
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
|
||||
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
|
||||
};
|
||||
} else if (region.region_type === 'webpage') {
|
||||
const submitted = body[`region_webpage_${region.id}`];
|
||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||
|
||||
function sanitizeBackgroundColor(value) {
|
||||
|
||||
+222
-2
@@ -27,23 +27,239 @@ 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 findTopLevelWhereIndex(sql) {
|
||||
const text = String(sql || '');
|
||||
let depth = 0;
|
||||
let inSingleQuote = false;
|
||||
let inDoubleQuote = false;
|
||||
let inBacktick = false;
|
||||
let lastWhereIndex = -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 && /[wW]/.test(character)) {
|
||||
const remaining = text.slice(index);
|
||||
if (/^where\b/i.test(remaining)) {
|
||||
lastWhereIndex = index;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lastWhereIndex;
|
||||
}
|
||||
|
||||
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 hasTopLevelWhere = findTopLevelWhereIndex(baseSelectSql) >= 0;
|
||||
const searchClause = searchFilter.clause ? (hasTopLevelWhere ? searchFilter.clause.replace(/^\s*WHERE\s+/i, ' AND ') : searchFilter.clause) : '';
|
||||
const filteredSelectSql = `${baseSelectSql}${searchClause}`;
|
||||
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 +293,10 @@ module.exports = {
|
||||
parseJsonSafe,
|
||||
readFormArray,
|
||||
normalizePageNumber,
|
||||
normalizeSortDirection,
|
||||
buildSortOrderClause,
|
||||
findTopLevelOrderByIndex,
|
||||
buildSearchFilter,
|
||||
fetchPagedRows,
|
||||
fetchDuplicateName
|
||||
};
|
||||
|
||||
+2
-1
@@ -117,7 +117,8 @@ async function ensureSchema(pool, options) {
|
||||
playlist_id INT NOT NULL,
|
||||
slide_id INT NOT NULL,
|
||||
position INT NOT NULL DEFAULT 0,
|
||||
duration_seconds INT NOT NULL DEFAULT 10,
|
||||
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
|
||||
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
||||
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||
schedule_start_datetime DATETIME NULL,
|
||||
schedule_end_datetime DATETIME NULL,
|
||||
|
||||
+31
-13
@@ -605,20 +605,20 @@ const migrations = [
|
||||
{
|
||||
key: 'schema-columns-current',
|
||||
version: appVersion,
|
||||
comment: 'Add the current table columns and audit fields that define the released schema.',
|
||||
comment: 'Backfill the released schema columns for older databases.',
|
||||
order: 20,
|
||||
up: async function (pool) {
|
||||
// v1.4.6: keep the current canvas_sizes shape available in older databases.
|
||||
// Current released schema: keep canvas_sizes audit fields available in older databases.
|
||||
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'canvas_sizes');
|
||||
|
||||
// v1.4.6: playlists gained fade_between_slides plus audit fields.
|
||||
// Current released schema: ensure playlists carry the playback and audit fields.
|
||||
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'playlists');
|
||||
|
||||
// v1.4.6: slide_templates now store canvas sizing and background metadata.
|
||||
// Current released schema: keep slide template canvas and background fields available.
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
||||
@@ -626,13 +626,13 @@ const migrations = [
|
||||
await addAuditColumns(pool, 'slide_templates');
|
||||
await backfillLegacySlideTemplateCanvasSize(pool);
|
||||
|
||||
// v1.4.6: slide_template_regions gained font family and audit fields.
|
||||
// Current released schema: keep slide template region typography and audit fields available.
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
|
||||
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'slide_template_regions');
|
||||
|
||||
// v1.4.6: slides gained structured content and media fields.
|
||||
// Current released schema: keep structured slide content and media fields available.
|
||||
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
||||
@@ -642,8 +642,8 @@ const migrations = [
|
||||
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'slides');
|
||||
|
||||
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
|
||||
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
||||
@@ -653,12 +653,12 @@ const migrations = [
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'playlist_slides');
|
||||
|
||||
// v1.4.6: screens gained an optional playlist binding and audit fields.
|
||||
// Current released schema: keep screen playlist bindings and audit fields available.
|
||||
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'screens');
|
||||
|
||||
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
|
||||
// Current released schema: keep RSS feed interval and audit fields available.
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||
@@ -667,14 +667,14 @@ const migrations = [
|
||||
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'rss_feeds');
|
||||
|
||||
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
|
||||
// Current released schema: keep normalized RSS feed item snapshots available.
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
|
||||
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await backfillLegacyRssFeedItemJson(pool);
|
||||
|
||||
// v1.4.6: API sources now track their latest response snapshot.
|
||||
// Current released schema: keep API source polling and snapshot fields available.
|
||||
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
|
||||
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||
@@ -687,7 +687,7 @@ const migrations = [
|
||||
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
await addAuditColumns(pool, 'api_sources');
|
||||
|
||||
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
|
||||
// Current released schema: keep onboarding device and RBAC audit fields available.
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
|
||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||
@@ -741,6 +741,24 @@ const migrations = [
|
||||
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'playlist-slide-use-video-duration-flag',
|
||||
version: appVersion,
|
||||
comment: 'Add the playlist slide flag that follows the current video duration.',
|
||||
order: 23,
|
||||
up: async function (pool) {
|
||||
await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'playlist-slide-duration-decimals',
|
||||
version: appVersion,
|
||||
comment: 'Widen playlist slide durations to preserve fractional seconds.',
|
||||
order: 24,
|
||||
up: async function (pool) {
|
||||
await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'media-path-prefix-rename',
|
||||
version: appVersion,
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
let slideMarkupCache = Object.create(null);
|
||||
let templateLayoutCache = Object.create(null);
|
||||
let templateRenderPlanCache = Object.create(null);
|
||||
let videoRegionRenderVersion = 0;
|
||||
let renderCacheViewportKey = '';
|
||||
let index = 0;
|
||||
let timer = null;
|
||||
|
||||
+40
-5
@@ -73,11 +73,11 @@ function createPlayerPlaylistService(options) {
|
||||
return payloadWithoutPlaylist;
|
||||
}
|
||||
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
|
||||
const playlist = playlistRows[0] || null;
|
||||
const [slideRows] = await pool.query(`
|
||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||
FROM playlist_slides ps
|
||||
JOIN slides sl ON sl.id = ps.slide_id
|
||||
@@ -108,12 +108,46 @@ function createPlayerPlaylistService(options) {
|
||||
});
|
||||
}
|
||||
|
||||
function getVideoRegionDurationSeconds(contentJson) {
|
||||
if (!contentJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = common.parseJsonSafe(contentJson) || {};
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
const slides = slideRows.map(function (slide) {
|
||||
const storedDuration = Number(slide.duration_seconds || 0);
|
||||
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
|
||||
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||
const content = common.parseJsonSafe(slide.content_json) || {};
|
||||
Object.keys(content).forEach(function (key) {
|
||||
const region = content[key];
|
||||
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
|
||||
region.cache_bust = videoCacheBust;
|
||||
}
|
||||
});
|
||||
return {
|
||||
id: slide.id,
|
||||
title: slide.title,
|
||||
body: slide.body,
|
||||
duration_seconds: slide.duration_seconds,
|
||||
modified_at: slide.modified_at,
|
||||
duration_seconds: videoDuration || storedDuration,
|
||||
use_video_duration: Boolean(slide.use_video_duration),
|
||||
schedule_mode: slide.schedule_mode,
|
||||
schedule_start_datetime: slide.schedule_start_datetime,
|
||||
schedule_end_datetime: slide.schedule_end_datetime,
|
||||
@@ -125,7 +159,7 @@ function createPlayerPlaylistService(options) {
|
||||
kind: common.mediaKind(slide.media_path),
|
||||
template_id: slide.template_id,
|
||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||
content: common.parseJsonSafe(slide.content_json) || {}
|
||||
content: content
|
||||
};
|
||||
});
|
||||
|
||||
@@ -193,6 +227,7 @@ function createPlayerPlaylistService(options) {
|
||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||
updatePlaylistRevisionHash(hash, slide.position);
|
||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||
|
||||
@@ -364,6 +364,19 @@ body.screen-blackout #app {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.template-region.video {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.template-region.video video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
pointer-events: none;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.template-region.webpage iframe {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 19 KiB |
@@ -89,10 +89,13 @@ function scheduleSlideAdvance(delayMs) {
|
||||
}, holdDelayMs);
|
||||
}
|
||||
|
||||
// Add the fade time to a slide's hold duration so the configured duration remains visible.
|
||||
// Account for fade only when it is enabled so the transition is centered on the slide boundary.
|
||||
function getSlideHoldDelay(delayMs) {
|
||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||
return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0);
|
||||
if (!currentPlaylistFadeBetweenSlides) {
|
||||
return holdDelayMs;
|
||||
}
|
||||
return Math.max(1, holdDelayMs - (slideFadeDurationMs / 2));
|
||||
}
|
||||
|
||||
// Cancel any pending fade-transition cleanup.
|
||||
@@ -109,11 +112,67 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
if (typeof destroyRtmpRegions === 'function') {
|
||||
destroyRtmpRegions(app);
|
||||
}
|
||||
|
||||
function initializeRenderedVideoPlayback(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var startDelayMs = Math.max(0, Number(delayMs || 0));
|
||||
var videos = root.querySelectorAll('.template-region.video video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
var playbackScheduled = false;
|
||||
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
function startPlayback() {
|
||||
var playPromise = video.play && video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function schedulePlaybackStart() {
|
||||
if (playbackScheduled) {
|
||||
return;
|
||||
}
|
||||
playbackScheduled = true;
|
||||
if (startDelayMs > 0) {
|
||||
window.setTimeout(startPlayback, startDelayMs);
|
||||
return;
|
||||
}
|
||||
startPlayback();
|
||||
}
|
||||
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', schedulePlaybackStart, { once: true });
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
if (video.readyState >= 2) {
|
||||
schedulePlaybackStart();
|
||||
}
|
||||
}, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
if (!shouldFade) {
|
||||
app.innerHTML = markup;
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(app);
|
||||
}
|
||||
initializeRenderedVideoPlayback(app);
|
||||
return app.firstElementChild;
|
||||
}
|
||||
|
||||
@@ -131,6 +190,28 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
});
|
||||
}
|
||||
|
||||
function pauseRenderedVideoPlayback(root, delayMs) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var pauseDelayMs = Math.max(0, Number(delayMs || 0));
|
||||
var videos = root.querySelectorAll('.template-region.video video, .slide-media video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (!video || typeof video.pause !== 'function') {
|
||||
return;
|
||||
}
|
||||
|
||||
window.setTimeout(function () {
|
||||
try {
|
||||
video.pause();
|
||||
} catch (_error) {
|
||||
// Ignore pause errors from detached or unsupported media elements.
|
||||
}
|
||||
}, pauseDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
var nextShell = document.createElement('div');
|
||||
nextShell.className = 'slide-shell';
|
||||
nextShell.style.opacity = '0';
|
||||
@@ -143,6 +224,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
if (typeof syncRtmpRegions === 'function') {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
return nextShell;
|
||||
}
|
||||
|
||||
@@ -150,6 +232,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
previousShell.classList.add('slide-shell');
|
||||
}
|
||||
previousShell.style.opacity = '1';
|
||||
pauseRenderedVideoPlayback(previousShell, slideFadeDurationMs / 2);
|
||||
|
||||
app.appendChild(nextShell);
|
||||
void nextShell.offsetHeight;
|
||||
@@ -162,6 +245,8 @@ function renderSlideMarkup(markup, shouldFade) {
|
||||
syncRtmpRegions(nextShell);
|
||||
}
|
||||
|
||||
initializeRenderedVideoPlayback(nextShell, slideFadeDurationMs / 2);
|
||||
|
||||
slideTransitionTimer = window.setTimeout(function () {
|
||||
if (previousShell && previousShell.parentNode) {
|
||||
previousShell.parentNode.removeChild(previousShell);
|
||||
@@ -280,7 +365,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);
|
||||
|
||||
@@ -147,7 +147,7 @@ function refresh() {
|
||||
markRefreshHealthy();
|
||||
setOfflineBannerVisible(false);
|
||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -452,6 +452,9 @@ function getTemplateRenderPlan(template) {
|
||||
if (region.regionType === 'image') {
|
||||
return renderImageRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'video') {
|
||||
return renderVideoRegion(region, regionContent);
|
||||
}
|
||||
if (region.regionType === 'webpage') {
|
||||
return renderWebpageRegion(region, regionContent);
|
||||
}
|
||||
@@ -517,9 +520,21 @@ function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHt
|
||||
// Build the cache key for rendered slide markup.
|
||||
function getSlideMarkupCacheKey(slide) {
|
||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|');
|
||||
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
|
||||
}
|
||||
|
||||
function notifyVideoRegionSourceReady() {
|
||||
if (typeof videoRegionRenderVersion === 'number') {
|
||||
videoRegionRenderVersion += 1;
|
||||
}
|
||||
slideMarkupCache = Object.create(null);
|
||||
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||
showCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
|
||||
|
||||
// Look up a previously rendered slide in the cache.
|
||||
function getCachedSlideMarkup(slide) {
|
||||
var cacheKey = getSlideMarkupCacheKey(slide);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
const CACHE_VERSION = 'v1';
|
||||
const CACHE_VERSION = 'v2';
|
||||
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
||||
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
||||
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
|
||||
@@ -87,6 +87,10 @@ async function staleWhileRevalidate(request, cacheName) {
|
||||
return new Response('', { status: 504, statusText: 'Offline' });
|
||||
}
|
||||
|
||||
async function networkOnly(request) {
|
||||
return fetch(request);
|
||||
}
|
||||
|
||||
self.addEventListener('install', function (event) {
|
||||
self.skipWaiting();
|
||||
event.waitUntil(Promise.resolve());
|
||||
@@ -126,7 +130,7 @@ self.addEventListener('fetch', function (event) {
|
||||
}
|
||||
|
||||
if (url.pathname.startsWith('/media/')) {
|
||||
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE));
|
||||
event.respondWith(networkOnly(request));
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
var videoRegionLastGoodSrcCache = Object.create(null);
|
||||
var videoRegionProbeStateCache = Object.create(null);
|
||||
var videoRegionProbeTimerCache = Object.create(null);
|
||||
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
|
||||
|
||||
function getVideoRegionCacheKey(region) {
|
||||
return String(region && (region.regionKey || region.label) || '').trim();
|
||||
}
|
||||
|
||||
function isDirectlyRenderableSource(src) {
|
||||
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
|
||||
}
|
||||
|
||||
function appendCacheBust(src, cacheBust) {
|
||||
var key = String(cacheBust || '').trim();
|
||||
var raw = String(src || '').trim();
|
||||
if (!raw || !key) {
|
||||
return raw;
|
||||
}
|
||||
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
|
||||
}
|
||||
|
||||
function getVideoSourceAvailability(src) {
|
||||
return videoRegionProbeStateCache[String(src || '').trim()] || null;
|
||||
}
|
||||
|
||||
function setVideoSourceAvailability(src, available) {
|
||||
var key = String(src || '').trim();
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
videoRegionProbeStateCache[key] = {
|
||||
available: available === null ? null : Boolean(available),
|
||||
checkedAt: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function logVideoRegionStatus(message, details, level) {
|
||||
if (typeof logDebug === 'function') {
|
||||
logDebug(message, details || '', level || 'info');
|
||||
}
|
||||
}
|
||||
|
||||
function triggerVideoRegionSourceRefresh() {
|
||||
if (typeof window.notifyVideoRegionSourceReady === 'function') {
|
||||
window.notifyVideoRegionSourceReady();
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof videoRegionRenderVersion === 'number') {
|
||||
videoRegionRenderVersion += 1;
|
||||
}
|
||||
slideMarkupCache = Object.create(null);
|
||||
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||
showCurrent();
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
|
||||
var key = String(src || '').trim();
|
||||
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
|
||||
if (key) {
|
||||
setVideoSourceAvailability(key, true);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (videoRegionProbeTimerCache[key]) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isRetry) {
|
||||
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
|
||||
} else {
|
||||
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||
}
|
||||
|
||||
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
|
||||
delete videoRegionProbeTimerCache[key];
|
||||
fetch(key, {
|
||||
method: 'HEAD',
|
||||
cache: 'no-store',
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (response && response.ok) {
|
||||
setVideoSourceAvailability(key, true);
|
||||
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = key;
|
||||
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
|
||||
triggerVideoRegionSourceRefresh();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoSourceAvailability(key, false);
|
||||
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||
scheduleVideoSourceProbe(regionKey, key, true);
|
||||
}).catch(function () {
|
||||
setVideoSourceAvailability(key, false);
|
||||
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||
scheduleVideoSourceProbe(regionKey, key, true);
|
||||
});
|
||||
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
|
||||
}
|
||||
|
||||
function renderVideoRegion(region, regionContent) {
|
||||
var requestedSrc = String(regionContent && regionContent.value || '').trim();
|
||||
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
|
||||
var regionKey = getVideoRegionCacheKey(region);
|
||||
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
|
||||
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
|
||||
|
||||
if (!requestedSrc) {
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||
}
|
||||
|
||||
if (isDirectlyRenderableSource(requestedSrc)) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
setVideoSourceAvailability(requestedSrc, true);
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||
var requestedReady = Boolean(requestedState && requestedState.available === true);
|
||||
|
||||
if (requestedReady) {
|
||||
if (regionKey) {
|
||||
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||
|
||||
if (cachedSrc) {
|
||||
if (cachedSrc !== requestedSrc) {
|
||||
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||
}
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||
}
|
||||
|
||||
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||
}
|
||||
@@ -286,6 +286,7 @@ const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'playe
|
||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||
const playerRegionScriptPaths = [
|
||||
path.join(__dirname, 'regions', 'image.js'),
|
||||
path.join(__dirname, 'regions', 'video.js'),
|
||||
path.join(__dirname, 'regions', 'webpage.js'),
|
||||
path.join(__dirname, 'regions', 'html.js'),
|
||||
path.join(__dirname, 'regions', 'rtmp.js'),
|
||||
|
||||
@@ -114,7 +114,7 @@ function registerPlayerRoutes(app, options) {
|
||||
});
|
||||
});
|
||||
|
||||
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) {
|
||||
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
|
||||
try {
|
||||
const filePath = resolveMediaFilePath(req.params.filename);
|
||||
if (!filePath) {
|
||||
|
||||
+11
-1
@@ -109,10 +109,20 @@ const PERMISSION_SECTIONS = [
|
||||
name: 'Background tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View background tasks and scheduled refreshes.' },
|
||||
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'scheduled-tasks',
|
||||
order: 101,
|
||||
name: 'Scheduled tasks',
|
||||
sectionName: 'Settings',
|
||||
actions: [
|
||||
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
|
||||
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
|
||||
]
|
||||
},
|
||||
{
|
||||
key: 'users',
|
||||
order: 80,
|
||||
|
||||
+25
-145
@@ -16,7 +16,8 @@ const registerAdminScreenCommandRoutes = require('./web/routes/admin/client-comm
|
||||
const registerAdminContentRoutes = require('./web/routes/admin/content');
|
||||
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
|
||||
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
|
||||
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
|
||||
const { createBackgroundTaskQueue } = require('./web/lib/background-task-queue');
|
||||
const { createBackgroundTaskSetup } = require('./web/lib/background-task-setup');
|
||||
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
|
||||
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
|
||||
const { createWebBootstrap } = require('./web/bootstrap');
|
||||
@@ -25,6 +26,7 @@ const rbacData = require('./web/lib/rbac-data');
|
||||
const { createPlayerActionService } = require('./web/lib/player-actions');
|
||||
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
|
||||
const { createSessionService } = require('./web/lib/session');
|
||||
const { hasAnyPermission } = require('./rbac');
|
||||
const {
|
||||
formatDashboardDate,
|
||||
readArrayField,
|
||||
@@ -92,15 +94,33 @@ async function start() {
|
||||
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
backgroundTaskQueue: backgroundTaskQueue
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
});
|
||||
const upload = webBootstrap.upload;
|
||||
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
|
||||
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
|
||||
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
||||
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
|
||||
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
||||
const backgroundTaskSetup = createBackgroundTaskSetup({
|
||||
pool: pool,
|
||||
common: common,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
captureSlideThumbnail: captureSlideThumbnail,
|
||||
refreshApiSource: refreshApiSource,
|
||||
refreshRssFeed: refreshRssFeed,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
mediaDir: MEDIA_DIR,
|
||||
uploadsDir: UPLOADS_DIR,
|
||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
dataSourceStartupRefreshStaggerMs: DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS
|
||||
});
|
||||
const broadcastDashboardState = webBootstrap.broadcastDashboardState;
|
||||
const sessionService = createSessionService({
|
||||
sessionCookieName: SESSION_COOKIE_NAME,
|
||||
@@ -115,51 +135,6 @@ async function start() {
|
||||
const createUserSession = sessionService.createUserSession;
|
||||
const requireAuth = sessionService.requireAuth;
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: MEDIA_DIR,
|
||||
baseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
app.use(express.json());
|
||||
app.use('/assets', express.static(ASSET_DIR));
|
||||
@@ -286,12 +261,14 @@ async function start() {
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
getAuditUserId: getAuditUserId,
|
||||
redirectAfterSave: redirectAfterSave,
|
||||
notifyPlayerScreens: notifyPlayerScreens,
|
||||
broadcastDashboardState: broadcastDashboardState,
|
||||
backgroundTaskQueue: backgroundTaskQueue,
|
||||
hasAnyPermission: hasAnyPermission,
|
||||
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
||||
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
||||
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
|
||||
@@ -368,104 +345,7 @@ async function start() {
|
||||
// Ensure schema and mirror media before the web service starts handling traffic.
|
||||
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||
await backgroundTaskQueue.initialize();
|
||||
|
||||
async function syncRecurringRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleInitialDataSourceRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push({
|
||||
type: 'api-source',
|
||||
id: Number(apiSource.id),
|
||||
name: apiSource.name,
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push({
|
||||
type: 'rss-feed',
|
||||
id: Number(rssFeed.id),
|
||||
name: rssFeed.name,
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
await syncRecurringRefreshes();
|
||||
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||
});
|
||||
await backgroundTaskSetup.initialize();
|
||||
|
||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
|
||||
|
||||
Vendored
+4
@@ -120,6 +120,8 @@ function createWebBootstrap(options) {
|
||||
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
||||
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
||||
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
||||
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
||||
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||
@@ -210,6 +212,8 @@ function createWebBootstrap(options) {
|
||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||
runMediaSyncTask: runMediaSyncTask,
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
function registerBackgroundTaskHandlers(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const captureSlideThumbnail = options && options.captureSlideThumbnail;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const runMediaSyncTask = options && options.runMediaSyncTask;
|
||||
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||
const playerInternalBaseUrl = String(options && options.playerInternalBaseUrl || '').trim();
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !captureSlideThumbnail || !refreshApiSource || !refreshRssFeed || !runMediaSyncTask || !mediaDir || !playerInternalBaseUrl) {
|
||||
throw new Error('registerBackgroundTaskHandlers requires the background task dependencies.');
|
||||
}
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('media-sync', function (task) {
|
||||
return runMediaSyncTask(task && task.payload ? task.payload : task);
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('data-source-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const sourceType = String(payload.sourceType || '').trim();
|
||||
const sourceId = Number(payload.sourceId || 0);
|
||||
|
||||
if (sourceType === 'api-source') {
|
||||
const apiSource = await common.fetchApiSourceById(pool, sourceId);
|
||||
if (!apiSource) {
|
||||
throw new Error('API source not found.');
|
||||
}
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
if (sourceType === 'rss-feed') {
|
||||
const rssFeed = await common.fetchRssFeedById(pool, sourceId);
|
||||
if (!rssFeed) {
|
||||
throw new Error('RSS feed not found.');
|
||||
}
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, Number(payload.actorId) || null);
|
||||
}
|
||||
|
||||
throw new Error('Unsupported data source refresh task.');
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const slideId = Number(payload.slideId || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
throw new Error('Slide id is required.');
|
||||
}
|
||||
|
||||
return captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: payload.previousThumbnailPath || null
|
||||
});
|
||||
});
|
||||
|
||||
backgroundTaskQueue.setTaskHandler('template-slide-thumbnail-refresh', async function (task) {
|
||||
const payload = task && task.payload ? task.payload : {};
|
||||
const templateId = Number(payload.templateId || 0);
|
||||
if (!Number.isFinite(templateId) || templateId <= 0) {
|
||||
throw new Error('Template id is required.');
|
||||
}
|
||||
|
||||
const [slides] = await pool.query(
|
||||
'SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC',
|
||||
[templateId]
|
||||
);
|
||||
|
||||
for (const slide of slides || []) {
|
||||
const slideId = Number(slide && slide.id || 0);
|
||||
if (!Number.isFinite(slideId) || slideId <= 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
await captureSlideThumbnail({
|
||||
pool: pool,
|
||||
common: common,
|
||||
mediaDir: mediaDir,
|
||||
baseUrl: playerInternalBaseUrl,
|
||||
slideId: slideId,
|
||||
previousThumbnailPath: slide && slide.thumbnail_path ? slide.thumbnail_path : null
|
||||
});
|
||||
}
|
||||
|
||||
return { templateId: templateId, slideCount: Array.isArray(slides) ? slides.length : 0 };
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = { registerBackgroundTaskHandlers };
|
||||
@@ -357,6 +357,16 @@ function createBackgroundTaskQueue(options) {
|
||||
scheduleRecurringRun(job, job.intervalMs);
|
||||
}
|
||||
|
||||
function runRecurringTask(recurringKey) {
|
||||
const normalizedKey = normalizeText(recurringKey);
|
||||
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
triggerRecurringJob(normalizedKey);
|
||||
return true;
|
||||
}
|
||||
|
||||
function syncRecurringTaskState(task, status, errorMessage) {
|
||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||
if (!recurringKey) {
|
||||
@@ -618,14 +628,24 @@ function createBackgroundTaskQueue(options) {
|
||||
return Array.from(tasksById.values())
|
||||
.slice()
|
||||
.sort(function (left, right) {
|
||||
const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
|
||||
return rightQueuedTime - leftQueuedTime;
|
||||
const leftStartedTime = left && left.startedAt ? Date.parse(left.startedAt) : NaN;
|
||||
const rightStartedTime = right && right.startedAt ? Date.parse(right.startedAt) : NaN;
|
||||
if (Number.isFinite(leftStartedTime) && Number.isFinite(rightStartedTime) && leftStartedTime !== rightStartedTime) {
|
||||
return rightStartedTime - leftStartedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
|
||||
return Number.isFinite(leftQueuedTime) ? -1 : 1;
|
||||
if (Number.isFinite(leftStartedTime) !== Number.isFinite(rightStartedTime)) {
|
||||
return Number.isFinite(leftStartedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
const leftCreatedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||
const rightCreatedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||
if (Number.isFinite(leftCreatedTime) && Number.isFinite(rightCreatedTime) && leftCreatedTime !== rightCreatedTime) {
|
||||
return rightCreatedTime - leftCreatedTime;
|
||||
}
|
||||
|
||||
if (Number.isFinite(leftCreatedTime) !== Number.isFinite(rightCreatedTime)) {
|
||||
return Number.isFinite(leftCreatedTime) ? -1 : 1;
|
||||
}
|
||||
|
||||
return right.id - left.id;
|
||||
@@ -737,6 +757,7 @@ function createBackgroundTaskQueue(options) {
|
||||
setTaskHandler: setTaskHandler,
|
||||
registerRecurringTask: registerRecurringTask,
|
||||
removeRecurringTask: removeRecurringTask,
|
||||
runRecurringTask: runRecurringTask,
|
||||
listTasks: listTasks,
|
||||
listRecurringTasks: listRecurringTasks,
|
||||
getTaskById: getTaskById,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
const { normalizeIntervalMs } = require('./background-task-queue');
|
||||
|
||||
function registerBackgroundTaskScheduling(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const uploadsDir = String(options && options.uploadsDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
|
||||
throw new Error('registerBackgroundTaskScheduling requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function syncRecurringRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'api-source-refresh:' + Number(apiSource.id),
|
||||
title: 'API source refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(apiSource.update_interval_value, apiSource.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'api-source',
|
||||
sourceId: Number(apiSource.id),
|
||||
sourceName: apiSource.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'rss-feed-refresh:' + Number(rssFeed.id),
|
||||
title: 'RSS feed refresh',
|
||||
category: 'data-source',
|
||||
intervalMs: normalizeIntervalMs(rssFeed.update_interval_value, rssFeed.update_interval_unit),
|
||||
metadata: {
|
||||
sourceType: 'rss-feed',
|
||||
sourceId: Number(rssFeed.id),
|
||||
sourceName: rssFeed.name
|
||||
},
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function registerRecurringMaintenanceTasks() {
|
||||
async function runUnusedUploadSweep() {
|
||||
const uploadPaths = await collectUploadPathsFromDirectory(uploadsDir);
|
||||
if (!uploadPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
await removeUnusedUploadFiles(pool, uploadsDir, uploadPaths);
|
||||
}
|
||||
|
||||
backgroundTaskQueue.registerRecurringTask({
|
||||
key: 'unused-upload-sweep',
|
||||
title: 'Unused upload sweep',
|
||||
category: 'media-sync',
|
||||
intervalMs: 24 * 60 * 60 * 1000,
|
||||
metadata: {
|
||||
uploadDir: uploadsDir
|
||||
},
|
||||
run: runUnusedUploadSweep
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleInitialDataSourceRefreshes() {
|
||||
const apiSourcesData = await common.fetchApiSourcesData(pool);
|
||||
const rssFeedsData = await common.fetchRssFeedsData(pool);
|
||||
const startupSources = [];
|
||||
|
||||
(apiSourcesData.apiSources || []).forEach(function (apiSource) {
|
||||
startupSources.push({
|
||||
type: 'api-source',
|
||||
id: Number(apiSource.id),
|
||||
name: apiSource.name,
|
||||
run: function () {
|
||||
return refreshApiSource(pool, common, apiSource.id, apiSource.api_url, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
(rssFeedsData.rssFeeds || []).forEach(function (rssFeed) {
|
||||
startupSources.push({
|
||||
type: 'rss-feed',
|
||||
id: Number(rssFeed.id),
|
||||
name: rssFeed.name,
|
||||
run: function () {
|
||||
return refreshRssFeed(pool, common, rssFeed.id, rssFeed.feed_url, rssFeed.item_limit, null);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
startupSources.forEach(function (source, index) {
|
||||
const startupDelayMs = index * dataSourceStartupRefreshStaggerMs;
|
||||
|
||||
setTimeout(function () {
|
||||
backgroundTaskQueue.enqueueTask({
|
||||
key: 'startup-data-source-refresh:' + source.type + ':' + source.id + ':' + Date.now(),
|
||||
title: source.type === 'rss-feed' ? 'RSS feed refresh' : 'API source refresh',
|
||||
category: 'data-source',
|
||||
taskType: 'data-source-refresh',
|
||||
metadata: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
},
|
||||
payload: {
|
||||
sourceType: source.type,
|
||||
sourceId: source.id,
|
||||
sourceName: source.name,
|
||||
startupRefresh: true
|
||||
}
|
||||
}).catch(function (error) {
|
||||
console.warn('Unable to queue startup refresh for ' + source.type + ' ' + source.id + ':', error);
|
||||
});
|
||||
}, startupDelayMs);
|
||||
});
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
await syncRecurringRefreshes();
|
||||
await registerRecurringMaintenanceTasks();
|
||||
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { registerBackgroundTaskScheduling };
|
||||
@@ -0,0 +1,32 @@
|
||||
const { registerBackgroundTaskHandlers } = require('./background-task-handlers');
|
||||
const { registerBackgroundTaskScheduling } = require('./background-task-scheduling');
|
||||
|
||||
function createBackgroundTaskSetup(options) {
|
||||
const pool = options && options.pool;
|
||||
const common = options && options.common;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const collectUploadPathsFromDirectory = options && options.collectUploadPathsFromDirectory;
|
||||
const removeUnusedUploadFiles = options && options.removeUnusedUploadFiles;
|
||||
const refreshApiSource = options && options.refreshApiSource;
|
||||
const refreshRssFeed = options && options.refreshRssFeed;
|
||||
const uploadsDir = String(options && options.uploadsDir || '').trim();
|
||||
const dataSourceStartupRefreshStaggerMs = Math.max(100, Number(options && options.dataSourceStartupRefreshStaggerMs || 250));
|
||||
|
||||
if (!pool || !common || !backgroundTaskQueue || !collectUploadPathsFromDirectory || !removeUnusedUploadFiles || !refreshApiSource || !refreshRssFeed || !uploadsDir) {
|
||||
throw new Error('createBackgroundTaskSetup requires the background task dependencies.');
|
||||
}
|
||||
|
||||
async function initialize() {
|
||||
registerBackgroundTaskHandlers(options);
|
||||
const backgroundTaskScheduling = registerBackgroundTaskScheduling(options);
|
||||
if (backgroundTaskScheduling && typeof backgroundTaskScheduling.initialize === 'function') {
|
||||
await backgroundTaskScheduling.initialize();
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
initialize: initialize
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = { createBackgroundTaskSetup };
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
};
|
||||
+66
-10
@@ -17,6 +17,64 @@ 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));
|
||||
const firstPage = 1;
|
||||
const lastPage = totalPages;
|
||||
|
||||
if (totalPages <= visiblePageCount) {
|
||||
for (let pageNumber = firstPage; pageNumber <= lastPage; pageNumber += 1) {
|
||||
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
|
||||
}
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
const innerPageCount = Math.max(1, visiblePageCount - 2);
|
||||
let startPage = Math.max(2, currentPage - Math.floor(innerPageCount / 2));
|
||||
let endPage = startPage + innerPageCount - 1;
|
||||
|
||||
if (endPage > lastPage - 1) {
|
||||
endPage = lastPage - 1;
|
||||
startPage = Math.max(2, endPage - innerPageCount + 1);
|
||||
}
|
||||
|
||||
pages.push(createPageEntry(firstPage, currentPage, pageParam, queryState));
|
||||
|
||||
if (startPage > 2) {
|
||||
pages.push(createEllipsisEntry());
|
||||
}
|
||||
|
||||
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
|
||||
pages.push(createPageEntry(pageNumber, currentPage, pageParam, queryState));
|
||||
}
|
||||
|
||||
if (endPage < lastPage - 1) {
|
||||
pages.push(createEllipsisEntry());
|
||||
}
|
||||
|
||||
pages.push(createPageEntry(lastPage, currentPage, pageParam, queryState));
|
||||
|
||||
return pages;
|
||||
}
|
||||
|
||||
function buildPagination(totalItems, currentPage, pageParam, queryState, pageSize, itemLabel, ariaLabel) {
|
||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||
const normalizedPageSize = Math.max(1, Number(pageSize) || 10);
|
||||
@@ -24,19 +82,12 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
|
||||
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
|
||||
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
|
||||
const pages = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
|
||||
pages.push({
|
||||
number: pageNumber,
|
||||
active: pageNumber === safeCurrentPage,
|
||||
url: buildQueryString(nextQuery)
|
||||
});
|
||||
}
|
||||
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
|
||||
|
||||
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,
|
||||
@@ -45,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,
|
||||
@@ -60,5 +115,6 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
|
||||
module.exports = {
|
||||
normalizePageNumber,
|
||||
buildQueryString,
|
||||
buildPaginationPages,
|
||||
buildPagination
|
||||
};
|
||||
@@ -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,11 @@ async function fetchUsersWithRoles(pool) {
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
||||
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection, options) {
|
||||
const excludedUserId = Number(options && options.excludeUserId);
|
||||
const hasExcludedUserId = Number.isInteger(excludedUserId) && excludedUserId > 0;
|
||||
const whereSql = hasExcludedUserId ? 'WHERE u.id <> ?' : '';
|
||||
const queryArgs = hasExcludedUserId ? [excludedUserId] : [];
|
||||
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,
|
||||
@@ -136,8 +152,21 @@ async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
||||
JOIN roles r ON r.id = ur.role_id
|
||||
GROUP BY ur.user_id
|
||||
) role_data ON role_data.user_id = u.id
|
||||
${whereSql}
|
||||
ORDER BY u.id ASC`,
|
||||
countSql: 'SELECT COUNT(*) AS count FROM users',
|
||||
countSql: `SELECT COUNT(*) AS count FROM users u ${whereSql}`,
|
||||
params: queryArgs,
|
||||
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
|
||||
});
|
||||
|
||||
@@ -98,6 +98,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'video') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
|
||||
: '';
|
||||
}
|
||||
|
||||
if (regionType === 'webpage') {
|
||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||
return src
|
||||
@@ -186,6 +193,42 @@ async function captureSlideThumbnail(options) {
|
||||
|
||||
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||
|
||||
async function waitForThumbnailRender(page) {
|
||||
await page.waitForFunction(function () {
|
||||
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.waitForFunction(function () {
|
||||
var canvas = document.querySelector('.slide-canvas');
|
||||
if (!canvas) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
|
||||
return images.every(function (image) {
|
||||
return image.complete && typeof image.naturalWidth === 'number';
|
||||
});
|
||||
}, { timeout: 30000 });
|
||||
|
||||
await page.evaluate(async function () {
|
||||
if (document.fonts && document.fonts.ready) {
|
||||
try {
|
||||
await document.fonts.ready;
|
||||
} catch (_error) {
|
||||
// Ignore font readiness failures and fall back to the rendered frame.
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.evaluate(function () {
|
||||
return new Promise(function (resolve) {
|
||||
window.requestAnimationFrame(function () {
|
||||
window.requestAnimationFrame(resolve);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
const browser = await launchBrowser();
|
||||
try {
|
||||
const page = await browser.newPage();
|
||||
@@ -198,9 +241,7 @@ async function captureSlideThumbnail(options) {
|
||||
}));
|
||||
await page.setViewport(PLAYER_VIEWPORT);
|
||||
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||
await page.waitForFunction(function () {
|
||||
return Boolean(document.querySelector('.slide-canvas'));
|
||||
}, { timeout: 30000 });
|
||||
await waitForThumbnailRender(page);
|
||||
const canvas = await page.$('.slide-canvas');
|
||||
if (!canvas) {
|
||||
throw new Error('Player render did not produce a slide canvas.');
|
||||
|
||||
@@ -15,6 +15,7 @@ function createUploadSyncService(options) {
|
||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
let playerUploadSyncMode = null;
|
||||
let playerUploadSyncModePromise = null;
|
||||
@@ -40,7 +41,12 @@ function createUploadSyncService(options) {
|
||||
cb(null, `${stamp}${safeExt}`);
|
||||
}
|
||||
});
|
||||
return multer({ storage });
|
||||
return multer({
|
||||
storage: storage,
|
||||
limits: {
|
||||
fileSize: MAX_UPLOAD_BYTES
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeUploadReference(uploadPath) {
|
||||
@@ -176,6 +182,51 @@ function createUploadSyncService(options) {
|
||||
}
|
||||
}
|
||||
|
||||
async function collectUploadPathsFromDirectory(uploadDir) {
|
||||
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||
if (!normalizedUploadDir) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const uploadPaths = [];
|
||||
|
||||
async function walkDirectory(currentDir, relativeDir) {
|
||||
let entries = [];
|
||||
try {
|
||||
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
||||
} catch (error) {
|
||||
if (error && error.code !== 'ENOENT') {
|
||||
console.warn('Unable to read upload directory:', currentDir, error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
for (const entry of entries) {
|
||||
const entryName = String(entry && entry.name || '').trim();
|
||||
if (!entryName || entryName === '.' || entryName === '..') {
|
||||
continue;
|
||||
}
|
||||
|
||||
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
|
||||
const nextAbsolutePath = path.join(currentDir, entryName);
|
||||
|
||||
if (entry.isDirectory && entry.isDirectory()) {
|
||||
await walkDirectory(nextAbsolutePath, nextRelativePath);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (entry.isFile && !entry.isFile()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
|
||||
}
|
||||
}
|
||||
|
||||
await walkDirectory(normalizedUploadDir, '');
|
||||
return uploadPaths;
|
||||
}
|
||||
|
||||
async function getPlayerUploadSyncMode(localUploadDir) {
|
||||
if (playerUploadSyncMode) {
|
||||
return playerUploadSyncMode;
|
||||
@@ -576,6 +627,10 @@ function createUploadSyncService(options) {
|
||||
if (mode === 'playlist') {
|
||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
if (operation.previousUploadRefs.length) {
|
||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||
@@ -583,10 +638,6 @@ function createUploadSyncService(options) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (operation.nextUploadRefs.length) {
|
||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||
}
|
||||
|
||||
if (operation.refreshScreenSlugs.length) {
|
||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||
if (refreshTargets.ready.length) {
|
||||
@@ -639,6 +690,7 @@ function createUploadSyncService(options) {
|
||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||
countUploadReferences: countUploadReferences,
|
||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
||||
shouldMirrorUploads: shouldMirrorUploads,
|
||||
queuePlayerUploadSync: queuePlayerUploadSync,
|
||||
|
||||
@@ -33,7 +33,8 @@ module.exports = {
|
||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')),
|
||||
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
|
||||
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
||||
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -290,7 +290,7 @@
|
||||
|
||||
.dashboard-action-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 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;
|
||||
@@ -759,6 +860,13 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-preview-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-preview-background {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -787,6 +895,246 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
z-index: 0;
|
||||
}
|
||||
|
||||
.slide-preview-popup-body {
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
color: #fff;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
|
||||
.slide-preview-popup-stage {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
background: #111;
|
||||
}
|
||||
|
||||
.slide-preview-popup-canvas {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
transform-origin: top left;
|
||||
will-change: transform;
|
||||
}
|
||||
|
||||
.slide-preview-popup-canvas,
|
||||
.slide-preview-popup-stage {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
height: 560px;
|
||||
min-height: 360px;
|
||||
max-height: 70vh;
|
||||
overflow: hidden;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background: var(--bs-body-bg);
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame img {
|
||||
display: block;
|
||||
max-width: 100%;
|
||||
max-height: 65vh;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame > .cropper-container {
|
||||
width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-width: 100% !important;
|
||||
max-height: 100% !important;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame > .cropper-container img {
|
||||
max-width: none !important;
|
||||
max-height: none !important;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame .cropper-container,
|
||||
.slide-image-cropper-frame .cropper-canvas,
|
||||
.slide-image-cropper-frame .cropper-wrap-box,
|
||||
.slide-image-cropper-frame .cropper-crop-box,
|
||||
.slide-image-cropper-frame .cropper-view-box,
|
||||
.slide-image-cropper-frame .cropper-face {
|
||||
border-radius: 0 !important;
|
||||
}
|
||||
|
||||
.slide-image-cropper-frame .cropper-wrap-box,
|
||||
.slide-image-cropper-frame .cropper-crop-box {
|
||||
border: 0 !important;
|
||||
}
|
||||
|
||||
.slide-image-cropper-toolbar .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-box {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
justify-items: end;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell {
|
||||
position: relative;
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 288px;
|
||||
height: 176px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell:focus-visible,
|
||||
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
|
||||
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
|
||||
.slide-image-region-preview-remove {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 4rem;
|
||||
height: 4rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 37, 41, 0.82);
|
||||
color: #fff;
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -50%) scale(0.92);
|
||||
transition: opacity 0.15s ease, transform 0.15s ease, background-color 0.15s ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
|
||||
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
|
||||
background: rgba(var(--bs-danger-rgb), 0.92);
|
||||
}
|
||||
|
||||
.slide-image-region-preview-remove i {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone {
|
||||
flex: 1 1 auto;
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 0.9rem;
|
||||
min-height: 176px;
|
||||
padding: 1rem 1.1rem;
|
||||
border: 1px dashed var(--bs-border-color);
|
||||
border-radius: 0;
|
||||
background: var(--bs-tertiary-bg);
|
||||
color: var(--bs-body-color);
|
||||
cursor: pointer;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-content,
|
||||
.slide-image-region-upload-zone-progress {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-progress {
|
||||
display: none;
|
||||
grid-column: 1 / -1;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-progress-label {
|
||||
font-weight: 600;
|
||||
color: var(--bs-body-color);
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-progress-bar {
|
||||
width: 100%;
|
||||
height: 0.85rem;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-limit {
|
||||
color: var(--bs-danger);
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone.is-uploading {
|
||||
grid-template-columns: 1fr;
|
||||
pointer-events: none;
|
||||
cursor: progress;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-content {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-progress {
|
||||
display: grid;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone:hover,
|
||||
.slide-image-region-upload-zone:focus-within,
|
||||
.slide-image-region-upload-zone.is-dragover {
|
||||
border-color: var(--bs-primary);
|
||||
background: var(--bs-secondary-bg);
|
||||
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.12);
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-icon {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 2.5rem;
|
||||
height: 2.5rem;
|
||||
font-size: 1.25rem;
|
||||
color: var(--bs-primary);
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-copy {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.slide-image-region-upload-zone-copy span {
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.slide-image-region-preview {
|
||||
display: block;
|
||||
width: 100%;
|
||||
max-width: 288px;
|
||||
height: 176px;
|
||||
object-fit: contain;
|
||||
border-radius: 0;
|
||||
background: var(--bs-tertiary-bg);
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-shell video.slide-image-region-preview {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.slide-image-region-preview-empty {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: 0;
|
||||
color: var(--bs-secondary-color);
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.template-designer-layout {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||
@@ -803,7 +1151,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
min-height: 18rem;
|
||||
overflow: hidden;
|
||||
/* overflow: hidden; */
|
||||
border: 1px solid var(--bs-border-color);
|
||||
border-radius: 0;
|
||||
background: var(--bs-body-bg);
|
||||
@@ -832,7 +1180,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
|
||||
.designer-overlay {
|
||||
z-index: 2;
|
||||
cursor: crosshair;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.designer-rect {
|
||||
@@ -840,9 +1188,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
border: 2px solid rgba(13, 110, 253, 0.95);
|
||||
background: rgba(13, 110, 253, 0.12);
|
||||
box-sizing: border-box;
|
||||
border-radius: 0.35rem;
|
||||
border-radius: 0;
|
||||
min-width: 12px;
|
||||
min-height: 12px;
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.designer-rect.selected {
|
||||
@@ -852,12 +1201,12 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
|
||||
.designer-rect-label {
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: -1.55rem;
|
||||
left: 0.4rem;
|
||||
top: 0.35rem;
|
||||
max-width: 100%;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
background: rgba(33, 37, 41, 0.88);
|
||||
background: rgba(33, 37, 41, 0.92);
|
||||
color: #fff;
|
||||
font-size: 0.75rem;
|
||||
line-height: 1.2;
|
||||
@@ -881,26 +1230,30 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
}
|
||||
|
||||
.resize-handle.nw {
|
||||
left: -0.4rem;
|
||||
top: -0.4rem;
|
||||
left: 0;
|
||||
top: 0;
|
||||
transform: translate(-50%, -50%);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
.resize-handle.ne {
|
||||
right: -0.4rem;
|
||||
top: -0.4rem;
|
||||
right: 0;
|
||||
top: 0;
|
||||
transform: translate(50%, -50%);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.sw {
|
||||
left: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
transform: translate(-50%, 50%);
|
||||
cursor: nesw-resize;
|
||||
}
|
||||
|
||||
.resize-handle.se {
|
||||
right: -0.4rem;
|
||||
bottom: -0.4rem;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
transform: translate(50%, 50%);
|
||||
cursor: nwse-resize;
|
||||
}
|
||||
|
||||
@@ -984,7 +1337,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.region-info-card .card-title {
|
||||
@@ -1130,8 +1482,14 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.region-item .remove-region {
|
||||
justify-self: end;
|
||||
.region-item [data-region-remove-button] {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.region-item [data-region-remove-button]:disabled {
|
||||
opacity: 0.65;
|
||||
box-shadow: none;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.region-item .form-control:focus,
|
||||
@@ -1194,6 +1552,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
z-index: 1085;
|
||||
}
|
||||
|
||||
.app-toast-container {
|
||||
top: calc(40px + 1rem);
|
||||
}
|
||||
|
||||
.is-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
@@ -1339,10 +1701,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-meta {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.playlist-slide-picker-check {
|
||||
position: absolute;
|
||||
top: 0.5rem;
|
||||
@@ -1393,9 +1751,8 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
||||
background: transparent;
|
||||
max-width: none;
|
||||
overflow: hidden;
|
||||
width: min(calc(100vw - 1rem), 42rem);
|
||||
max-height: calc(100vh - 1rem);
|
||||
height: auto;
|
||||
width: min(calc(100vw - 2rem), 56rem);
|
||||
max-height: calc(100vh - 0.25rem);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
@@ -1444,6 +1801,34 @@ td[data-label="Actions"] > div {
|
||||
min-width: 6rem;
|
||||
}
|
||||
|
||||
.playlist-duration-input::-webkit-outer-spin-button,
|
||||
.playlist-duration-input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.playlist-duration-input {
|
||||
-moz-appearance: textfield;
|
||||
appearance: textfield;
|
||||
}
|
||||
|
||||
.playlist-duration-field {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
gap: 0.5rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.playlist-use-video-duration {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
td[data-label="Duration"] {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
table.table > :not(caption) > * > * {
|
||||
vertical-align: middle;
|
||||
}
|
||||
@@ -1582,16 +1967,6 @@ table.table thead th.sort-desc .table-sort-indicator {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.dashboard-hero-body {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.dashboard-hero-stats {
|
||||
grid-template-columns: 1fr;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.page-header {
|
||||
align-items: flex-start;
|
||||
flex-direction: column;
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 19 KiB |
@@ -1,599 +0,0 @@
|
||||
(function () {
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.getAttribute) {
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
|
||||
return;
|
||||
}
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function markFormDirty(form) {
|
||||
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
|
||||
return;
|
||||
}
|
||||
form.dataset.dirty = 'true';
|
||||
}
|
||||
|
||||
function clearFormDirty(form) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
form.dataset.dirty = 'false';
|
||||
}
|
||||
|
||||
function isFormDirty(form) {
|
||||
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
|
||||
}
|
||||
|
||||
function initDirtyTracking() {
|
||||
document.addEventListener('input', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.form) {
|
||||
return;
|
||||
}
|
||||
markFormDirty(target.form);
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initCancelConfirm() {
|
||||
document.addEventListener('click', function (event) {
|
||||
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
|
||||
if (!cancelTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
|
||||
if (!isFormDirty(form)) {
|
||||
return;
|
||||
}
|
||||
|
||||
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
|
||||
if (!window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
}
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initAsyncCommandForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var formData = new FormData(form);
|
||||
var body = new URLSearchParams();
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
body: body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initAsyncSaveForms() {
|
||||
var refreshSequence = 0;
|
||||
|
||||
function setSaveActionValue(form, value) {
|
||||
if (!form) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
if (!hiddenInput) {
|
||||
hiddenInput = document.createElement('input');
|
||||
hiddenInput.type = 'hidden';
|
||||
hiddenInput.name = 'save_action';
|
||||
form.appendChild(hiddenInput);
|
||||
}
|
||||
|
||||
hiddenInput.value = String(value || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getResponseQueryValue(responseUrl, key) {
|
||||
try {
|
||||
var url = new URL(responseUrl, window.location.href);
|
||||
return String(url.searchParams.get(key) || '').trim();
|
||||
} catch (_error) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
function rebindRefreshTarget(targetElement) {
|
||||
if (!targetElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initJsonTogglePanels === 'function') {
|
||||
window.initJsonTogglePanels(targetElement);
|
||||
}
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(window.location.href, {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/xhtml+xml'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to refresh saved data.');
|
||||
}
|
||||
return response.text();
|
||||
}).then(function (text) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
if (!currentTarget || !nextTarget) {
|
||||
return;
|
||||
}
|
||||
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}).catch(function (_error) {
|
||||
// Ignore refresh replacement failures and leave the existing content in place.
|
||||
});
|
||||
}
|
||||
|
||||
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
|
||||
var pollDelayMs = 1000;
|
||||
var maxAttempts = 60;
|
||||
|
||||
function poll(attempt) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var stateUrl;
|
||||
try {
|
||||
stateUrl = new URL(refreshStateUrl, window.location.href);
|
||||
} catch (_error) {
|
||||
return;
|
||||
}
|
||||
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
|
||||
|
||||
fetch(stateUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'application/json, text/plain, */*'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to check refresh status.');
|
||||
}
|
||||
return response.json();
|
||||
}).then(function (payload) {
|
||||
if (sequenceId !== refreshSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var status = String(payload && payload.status || '').trim().toLowerCase();
|
||||
if (status === 'queued' || status === 'running') {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
|
||||
}).catch(function () {
|
||||
if (attempt < maxAttempts) {
|
||||
window.setTimeout(function () {
|
||||
poll(attempt + 1);
|
||||
}, pollDelayMs);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
poll(0);
|
||||
}
|
||||
|
||||
document.addEventListener('click', function (event) {
|
||||
var target = event.target;
|
||||
if (!target || !target.closest) {
|
||||
return;
|
||||
}
|
||||
|
||||
var button = target.closest('button[name="save_action"]');
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
var form = button.form || button.closest('form');
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSaveActionValue(form, button.value || '');
|
||||
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
|
||||
}, true);
|
||||
|
||||
document.addEventListener('submit', function (event) {
|
||||
var form = event.target;
|
||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (form.dataset && form.dataset.busy === 'true') {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
var message = form.getAttribute('data-confirm-message');
|
||||
if (message && !window.confirm(message)) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
form.dataset.busy = 'true';
|
||||
|
||||
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
|
||||
var formData = new FormData(form);
|
||||
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
||||
if (event.submitter && event.submitter.name) {
|
||||
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
||||
formData.set(event.submitter.name, event.submitter.value || '');
|
||||
}
|
||||
var hasFileValue = false;
|
||||
formData.forEach(function (value) {
|
||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
||||
hasFileValue = true;
|
||||
}
|
||||
});
|
||||
|
||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
||||
var body = isMultipart ? formData : new URLSearchParams();
|
||||
|
||||
if (!isMultipart) {
|
||||
formData.forEach(function (value, key) {
|
||||
body.append(key, value);
|
||||
});
|
||||
}
|
||||
|
||||
fetch(form.action, {
|
||||
method: (form.method || 'POST').toUpperCase(),
|
||||
headers: Object.assign({
|
||||
'X-Requested-With': 'XMLHttpRequest',
|
||||
'Accept': 'text/html, application/json, text/plain, */*'
|
||||
}, isMultipart ? {} : {
|
||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
||||
}),
|
||||
body: isMultipart ? body : body.toString(),
|
||||
credentials: 'same-origin'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
return response.text().then(function (text) {
|
||||
var error = new Error(text || 'Unable to save changes.');
|
||||
error.status = response.status;
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
var actionUrl = '';
|
||||
try {
|
||||
actionUrl = new URL(form.action, window.location.href).pathname;
|
||||
} catch (_error) {
|
||||
actionUrl = String(form.action || '');
|
||||
}
|
||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
||||
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
|
||||
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
|
||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
||||
var redirectUrl = submitterValue === 'close'
|
||||
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
|
||||
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
|
||||
window.location.replace(redirectUrl);
|
||||
return;
|
||||
}
|
||||
if (shouldFollowRedirect && response.url) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
||||
clearFormDirty(form);
|
||||
window.location.replace(response.url || window.location.href);
|
||||
return;
|
||||
}
|
||||
clearFormDirty(form);
|
||||
return response.text().then(function (text) {
|
||||
var savedMessage = '';
|
||||
var responseDocument = null;
|
||||
try {
|
||||
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
var toastBody = responseDocument.querySelector('.toast-body');
|
||||
if (toastBody && toastBody.textContent) {
|
||||
savedMessage = toastBody.textContent.trim();
|
||||
}
|
||||
} catch (_error) {
|
||||
savedMessage = '';
|
||||
}
|
||||
|
||||
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
||||
refreshSequence += 1;
|
||||
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
||||
} else if (refreshTargetSelector && responseDocument) {
|
||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
||||
if (currentTarget && nextTarget) {
|
||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||
}
|
||||
}
|
||||
|
||||
showToast(savedMessage || 'Saved.', 'success');
|
||||
});
|
||||
}).catch(function (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
showToast(error.message || 'Unable to save changes.', variant);
|
||||
return;
|
||||
}
|
||||
window.alert(error.message || 'Unable to save changes.');
|
||||
}).finally(function () {
|
||||
delete form.dataset.busy;
|
||||
delete form.dataset.submitterValue;
|
||||
if (hiddenSaveAction) {
|
||||
hiddenSaveAction.value = '';
|
||||
}
|
||||
});
|
||||
}, true);
|
||||
}
|
||||
|
||||
function initSubmitOnChange() {
|
||||
var fields = document.querySelectorAll('[data-submit-on-change]');
|
||||
Array.prototype.forEach.call(fields, function (field) {
|
||||
field.addEventListener('change', function () {
|
||||
var form = field.form || field.closest('form');
|
||||
if (form) {
|
||||
form.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initJsonTogglePanels(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
|
||||
Array.prototype.forEach.call(panels, function (panel) {
|
||||
var output = panel.querySelector('[data-json-toggle-output]');
|
||||
if (!output) {
|
||||
return;
|
||||
}
|
||||
|
||||
var card = panel.closest ? panel.closest('.card') : null;
|
||||
var button = card ? card.querySelector('[data-json-toggle]') : null;
|
||||
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
|
||||
var sourceNode = panel.querySelector('[data-json-toggle-source]');
|
||||
var rawJson = '';
|
||||
try {
|
||||
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
|
||||
} catch (_error) {
|
||||
rawJson = '';
|
||||
}
|
||||
|
||||
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var parsedJson;
|
||||
try {
|
||||
parsedJson = JSON.parse(rawJson);
|
||||
} catch (_error) {
|
||||
if (button) {
|
||||
button.classList.add('d-none');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var compactJson = JSON.stringify(parsedJson);
|
||||
var formattedJson = JSON.stringify(parsedJson, null, 2);
|
||||
var isFormatted = true;
|
||||
|
||||
function syncButtonLabel() {
|
||||
if (!button || !label) {
|
||||
return;
|
||||
}
|
||||
label.textContent = isFormatted
|
||||
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
|
||||
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
|
||||
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function syncOutput() {
|
||||
output.textContent = isFormatted ? formattedJson : compactJson;
|
||||
syncButtonLabel();
|
||||
}
|
||||
|
||||
output.textContent = formattedJson;
|
||||
syncButtonLabel();
|
||||
|
||||
if (button) {
|
||||
button.addEventListener('click', function () {
|
||||
isFormatted = !isFormatted;
|
||||
syncOutput();
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function initLocalDateTimes(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
var elements = scope.querySelectorAll('[data-local-datetime]');
|
||||
Array.prototype.forEach.call(elements, function (element) {
|
||||
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
|
||||
if (!rawValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
var date = new Date(rawValue);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.textContent = formatDashboardDate(date);
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDirtyTracking();
|
||||
initCancelConfirm();
|
||||
initAsyncCommandForms();
|
||||
initAsyncSaveForms();
|
||||
initSubmitOnChange();
|
||||
initJsonTogglePanels();
|
||||
initLocalDateTimes();
|
||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||
window.initLocalDateTimes = initLocalDateTimes;
|
||||
}());
|
||||
@@ -1,84 +1,11 @@
|
||||
(function () {
|
||||
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
var webUiHelpers = window.webUiHelpers || {};
|
||||
var escapeHtml = webUiHelpers.escapeHtml;
|
||||
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
|
||||
function initConfirmForms() {
|
||||
document.addEventListener('submit', function (event) {
|
||||
@@ -237,9 +164,6 @@
|
||||
if (typeof window.initTableSearches === 'function') {
|
||||
window.initTableSearches(targetElement);
|
||||
}
|
||||
if (typeof window.initTablePaginations === 'function') {
|
||||
window.initTablePaginations(targetElement);
|
||||
}
|
||||
}
|
||||
|
||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||
@@ -274,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.
|
||||
});
|
||||
}
|
||||
@@ -588,303 +512,10 @@
|
||||
});
|
||||
}
|
||||
|
||||
function initTablePaginations(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
|
||||
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
|
||||
if (container.getAttribute('data-table-pagination-bound') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
var table = container.querySelector('[data-table-searchable]');
|
||||
var cardHeader = container.querySelector('.card-header');
|
||||
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
|
||||
var footer = container.querySelector('[data-table-pagination-controls]');
|
||||
var footerSummary = null;
|
||||
var footerList = null;
|
||||
var syncFrame = 0;
|
||||
var state = {
|
||||
currentPage: 1,
|
||||
pageSize: 1,
|
||||
totalPages: 1
|
||||
};
|
||||
|
||||
if (!table || !cardBody || !table.tBodies.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
container.setAttribute('data-table-pagination-bound', 'true');
|
||||
|
||||
function getRows() {
|
||||
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
||||
}
|
||||
|
||||
function isSearchMatched(row) {
|
||||
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
|
||||
}
|
||||
|
||||
function ensureFooter() {
|
||||
if (footer) {
|
||||
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||
return footer;
|
||||
}
|
||||
|
||||
footer = document.createElement('div');
|
||||
footer.className = 'card-footer d-none';
|
||||
footer.setAttribute('data-table-pagination-controls', 'true');
|
||||
footer.innerHTML = '' +
|
||||
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
|
||||
'<div class="text-muted small" data-table-pagination-summary></div>' +
|
||||
'<nav aria-label="Table pages">' +
|
||||
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
|
||||
'</nav>' +
|
||||
'</div>';
|
||||
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
|
||||
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||
return footer;
|
||||
}
|
||||
|
||||
function setRowVisible(row, visible) {
|
||||
row.hidden = !visible;
|
||||
row.classList.toggle('d-none', !visible);
|
||||
}
|
||||
|
||||
function getAvailableHeight() {
|
||||
var containerRect = container.getBoundingClientRect();
|
||||
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
|
||||
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
|
||||
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
|
||||
}
|
||||
|
||||
function renderFooter(totalItems) {
|
||||
var pageButtons = [];
|
||||
|
||||
ensureFooter();
|
||||
|
||||
if (state.totalPages <= 1 || totalItems <= 0) {
|
||||
footer.classList.add('d-none');
|
||||
footerSummary.textContent = '';
|
||||
footerList.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
footer.classList.remove('d-none');
|
||||
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
|
||||
|
||||
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
|
||||
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
|
||||
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
|
||||
}
|
||||
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
|
||||
footerList.innerHTML = pageButtons.join('');
|
||||
}
|
||||
|
||||
function applyPagination(resetPage) {
|
||||
var rows = getRows();
|
||||
var matchedRows = rows.filter(isSearchMatched);
|
||||
var totalItems = matchedRows.length;
|
||||
var rowHeight = 48;
|
||||
var pageSize;
|
||||
var startIndex;
|
||||
var endIndex;
|
||||
|
||||
if (resetPage) {
|
||||
state.currentPage = 1;
|
||||
}
|
||||
|
||||
rows.forEach(function (row) {
|
||||
if (!isSearchMatched(row)) {
|
||||
setRowVisible(row, false);
|
||||
}
|
||||
});
|
||||
|
||||
if (!totalItems) {
|
||||
state.pageSize = 1;
|
||||
state.totalPages = 1;
|
||||
state.currentPage = 1;
|
||||
renderFooter(0);
|
||||
return;
|
||||
}
|
||||
|
||||
matchedRows.forEach(function (row) {
|
||||
setRowVisible(row, true);
|
||||
});
|
||||
|
||||
if (matchedRows[0]) {
|
||||
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
|
||||
}
|
||||
|
||||
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
|
||||
|
||||
if (pageSize >= totalItems) {
|
||||
state.pageSize = totalItems;
|
||||
state.totalPages = 1;
|
||||
state.currentPage = 1;
|
||||
matchedRows.forEach(function (row) {
|
||||
setRowVisible(row, true);
|
||||
});
|
||||
renderFooter(totalItems);
|
||||
return;
|
||||
}
|
||||
|
||||
state.pageSize = pageSize;
|
||||
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
|
||||
startIndex = (state.currentPage - 1) * pageSize;
|
||||
endIndex = startIndex + pageSize;
|
||||
|
||||
matchedRows.forEach(function (row, index) {
|
||||
setRowVisible(row, index >= startIndex && index < endIndex);
|
||||
});
|
||||
|
||||
renderFooter(totalItems);
|
||||
}
|
||||
|
||||
function scheduleSync(options) {
|
||||
if (syncFrame) {
|
||||
window.cancelAnimationFrame(syncFrame);
|
||||
}
|
||||
|
||||
syncFrame = window.requestAnimationFrame(function () {
|
||||
syncFrame = 0;
|
||||
applyPagination(Boolean(options && options.resetPage));
|
||||
});
|
||||
}
|
||||
|
||||
ensureFooter();
|
||||
|
||||
if (footer) {
|
||||
footer.addEventListener('click', function (event) {
|
||||
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
|
||||
var pageValue;
|
||||
|
||||
if (!target || target.closest('.disabled')) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
|
||||
state.currentPage = pageValue;
|
||||
scheduleSync();
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('resize', function () {
|
||||
scheduleSync();
|
||||
});
|
||||
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
try {
|
||||
new ResizeObserver(function () {
|
||||
scheduleSync();
|
||||
}).observe(container);
|
||||
} catch (_error) {
|
||||
// Ignore observer setup failures and rely on window resize.
|
||||
}
|
||||
}
|
||||
|
||||
container.syncTablePagination = scheduleSync;
|
||||
scheduleSync({ resetPage: true });
|
||||
});
|
||||
}
|
||||
|
||||
function initTableSearches(root) {
|
||||
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() {
|
||||
@@ -914,10 +545,6 @@
|
||||
});
|
||||
}
|
||||
|
||||
if (window.initSortableTables) {
|
||||
window.initSortableTables();
|
||||
}
|
||||
|
||||
initConfirmForms();
|
||||
initDirtyTracking();
|
||||
initCancelConfirm();
|
||||
@@ -927,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;
|
||||
}());
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
(function () {
|
||||
function getGroupCheckboxes(group) {
|
||||
return Array.prototype.slice.call(group.querySelectorAll('input[name="permission_keys[]"]'));
|
||||
}
|
||||
|
||||
function getPermissionKey(checkbox) {
|
||||
return String((checkbox && (checkbox.getAttribute('data-permission-key') || checkbox.value)) || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function getActionKey(checkbox) {
|
||||
var permissionKey = getPermissionKey(checkbox);
|
||||
var parts = permissionKey.split('.');
|
||||
if (parts.length !== 2) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return String(parts[1] || '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function syncPermissionGroup(group) {
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
if (!checkboxes.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var readCheckbox = null;
|
||||
var nonReadChecked = false;
|
||||
|
||||
checkboxes.forEach(function (checkbox) {
|
||||
if (getActionKey(checkbox) === 'read') {
|
||||
readCheckbox = checkbox;
|
||||
return;
|
||||
}
|
||||
if (checkbox.checked) {
|
||||
nonReadChecked = true;
|
||||
}
|
||||
});
|
||||
|
||||
if (!readCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!readCheckbox.checked && nonReadChecked) {
|
||||
readCheckbox.checked = true;
|
||||
}
|
||||
|
||||
if (!readCheckbox.checked) {
|
||||
checkboxes.forEach(function (checkbox) {
|
||||
if (getActionKey(checkbox) !== 'read') {
|
||||
checkbox.checked = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function handleGroupChange(event) {
|
||||
var checkbox = event.target && event.target.matches ? event.target : null;
|
||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||
return;
|
||||
}
|
||||
|
||||
var actionKey = getActionKey(checkbox);
|
||||
var group = checkbox.closest('.accordion-item');
|
||||
if (!group) {
|
||||
return;
|
||||
}
|
||||
|
||||
var checkboxes = getGroupCheckboxes(group);
|
||||
var readCheckbox = checkboxes.find(function (candidate) {
|
||||
return getActionKey(candidate) === 'read';
|
||||
}) || null;
|
||||
|
||||
if (!readCheckbox) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionKey === 'read' && !checkbox.checked) {
|
||||
checkboxes.forEach(function (candidate) {
|
||||
if (candidate !== checkbox) {
|
||||
candidate.checked = false;
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (actionKey !== 'read' && checkbox.checked) {
|
||||
readCheckbox.checked = true;
|
||||
}
|
||||
}
|
||||
|
||||
function initPermissionGroups() {
|
||||
document.querySelectorAll('.accordion-item').forEach(function (group) {
|
||||
syncPermissionGroup(group);
|
||||
});
|
||||
|
||||
document.addEventListener('change', function (event) {
|
||||
var checkbox = event.target;
|
||||
if (!checkbox || checkbox.tagName !== 'INPUT' || checkbox.type !== 'checkbox') {
|
||||
return;
|
||||
}
|
||||
if (String(checkbox.getAttribute('name') || '') !== 'permission_keys[]') {
|
||||
return;
|
||||
}
|
||||
|
||||
handleGroupChange(event);
|
||||
syncPermissionGroup(checkbox.closest('.accordion-item'));
|
||||
});
|
||||
}
|
||||
|
||||
initPermissionGroups();
|
||||
}());
|
||||
@@ -1,102 +0,0 @@
|
||||
(function () {
|
||||
function updateSidebarStatus(status, label) {
|
||||
var dot = document.getElementById('sidebar-status-dot');
|
||||
var text = document.getElementById('sidebar-status-text');
|
||||
var pill = document.getElementById('sidebar-status-pill');
|
||||
if (!dot && !text && !pill) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalizedStatus = status === 'online' || status === 'offline' || status === 'unknown'
|
||||
? status
|
||||
: (status ? 'online' : 'offline');
|
||||
var statusLabel = normalizedStatus === 'online'
|
||||
? (label || 'Connected to player feed')
|
||||
: normalizedStatus === 'offline'
|
||||
? 'Disconnected from player feed'
|
||||
: (label || 'Connecting to player feed');
|
||||
var pillLabel = normalizedStatus === 'online' ? 'Connected' : normalizedStatus === 'offline' ? 'Disconnected' : 'Connecting';
|
||||
|
||||
dot.classList.remove('status-dot--unknown', 'status-dot--online', 'status-dot--offline');
|
||||
dot.classList.add(normalizedStatus === 'online' ? 'status-dot--online' : normalizedStatus === 'offline' ? 'status-dot--offline' : 'status-dot--unknown');
|
||||
dot.setAttribute('aria-label', statusLabel);
|
||||
dot.setAttribute('title', statusLabel);
|
||||
|
||||
if (text) {
|
||||
text.textContent = statusLabel;
|
||||
}
|
||||
|
||||
if (pill) {
|
||||
pill.classList.remove('status-pill--unknown', 'status-pill--online', 'status-pill--offline');
|
||||
pill.classList.add(normalizedStatus === 'online' ? 'status-pill--online' : normalizedStatus === 'offline' ? 'status-pill--offline' : 'status-pill--unknown');
|
||||
pill.textContent = pillLabel;
|
||||
}
|
||||
}
|
||||
|
||||
function connectDashboardSocket() {
|
||||
if (!window.WebSocket) {
|
||||
return;
|
||||
}
|
||||
|
||||
var hasStatusIndicators = document.getElementById('sidebar-status-dot') || document.getElementById('sidebar-status-text') || document.getElementById('sidebar-status-pill');
|
||||
if (!hasStatusIndicators) {
|
||||
return;
|
||||
}
|
||||
|
||||
var socket = null;
|
||||
var reconnectTimer = null;
|
||||
|
||||
function scheduleReconnect() {
|
||||
if (reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
reconnectTimer = window.setTimeout(function () {
|
||||
reconnectTimer = null;
|
||||
connect();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function connect() {
|
||||
var protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
socket = new WebSocket(protocol + '//' + window.location.host + '/ws/dashboard');
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
|
||||
socket.onmessage = function (event) {
|
||||
try {
|
||||
var payload = JSON.parse(String(event.data || '{}'));
|
||||
if (payload && payload.type === 'dashboard-state') {
|
||||
if (typeof window.webHandleDashboardState === 'function') {
|
||||
window.webHandleDashboardState(payload.state);
|
||||
}
|
||||
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
|
||||
}
|
||||
} catch (_error) {
|
||||
// Ignore malformed dashboard payloads.
|
||||
}
|
||||
};
|
||||
|
||||
socket.onclose = function () {
|
||||
updateSidebarStatus('offline');
|
||||
scheduleReconnect();
|
||||
};
|
||||
|
||||
socket.onerror = function () {
|
||||
updateSidebarStatus('offline');
|
||||
try {
|
||||
socket.close();
|
||||
} catch (_error) {
|
||||
// ignore close errors
|
||||
}
|
||||
};
|
||||
|
||||
socket.onopen = function () {
|
||||
updateSidebarStatus('unknown', 'Connecting to player feed');
|
||||
};
|
||||
}
|
||||
|
||||
connect();
|
||||
}
|
||||
|
||||
connectDashboardSocket();
|
||||
}());
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
(function () {
|
||||
var THEME_STORAGE_KEY = 'web-theme';
|
||||
var CKEDITOR_THEME_STYLE_ID = 'ckeditor-dark-theme-overrides';
|
||||
|
||||
function getPreferredTheme() {
|
||||
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
|
||||
return 'dark';
|
||||
}
|
||||
return 'light';
|
||||
}
|
||||
|
||||
function getStoredTheme() {
|
||||
try {
|
||||
var storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY);
|
||||
if (storedTheme === 'dark' || storedTheme === 'light') {
|
||||
return storedTheme;
|
||||
}
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function setStoredTheme(theme) {
|
||||
try {
|
||||
window.localStorage.setItem(THEME_STORAGE_KEY, theme);
|
||||
} catch (error) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function getOrCreateCkeditorThemeStyleElement() {
|
||||
var styleElement = document.getElementById(CKEDITOR_THEME_STYLE_ID);
|
||||
|
||||
if (styleElement) {
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
styleElement = document.createElement('style');
|
||||
styleElement.id = CKEDITOR_THEME_STYLE_ID;
|
||||
document.head.appendChild(styleElement);
|
||||
|
||||
return styleElement;
|
||||
}
|
||||
|
||||
function syncCkeditorTheme(theme) {
|
||||
var styleElement = getOrCreateCkeditorThemeStyleElement();
|
||||
|
||||
if (theme !== 'dark') {
|
||||
styleElement.textContent = '';
|
||||
return;
|
||||
}
|
||||
|
||||
styleElement.textContent = [
|
||||
'.ck-body-wrapper .ck.ck-dropdown__panel,',
|
||||
'.ck-body-wrapper .ck.ck-list__panel,',
|
||||
'.ck-body-wrapper .ck.ck-list,',
|
||||
'.ck-body-wrapper .ck.ck-balloon-panel {',
|
||||
' background: var(--bs-body-bg) !important;',
|
||||
' background-color: var(--bs-body-bg) !important;',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-list .ck-list-item-button {',
|
||||
' background: transparent !important;',
|
||||
' background-color: transparent !important;',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-list .ck-list-item-button:hover {',
|
||||
' background: var(--bs-secondary-bg) !important;',
|
||||
' background-color: var(--bs-secondary-bg) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-color-grid,',
|
||||
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
||||
' color: var(--bs-body-color) !important;',
|
||||
'}',
|
||||
'.ck-body-wrapper .ck.ck-color-grid__tile {',
|
||||
' border-color: var(--bs-border-color) !important;',
|
||||
'}'
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
var normalizedTheme = theme === 'dark' ? 'dark' : 'light';
|
||||
var nextTheme = normalizedTheme === 'dark' ? 'light' : 'dark';
|
||||
var nextThemeLabel = nextTheme === 'dark' ? 'Dark mode' : 'Light mode';
|
||||
|
||||
document.documentElement.dataset.bsTheme = normalizedTheme;
|
||||
document.documentElement.style.colorScheme = normalizedTheme;
|
||||
syncCkeditorTheme(normalizedTheme);
|
||||
|
||||
Array.prototype.forEach.call(document.querySelectorAll('[data-theme-toggle]'), function (toggleButton) {
|
||||
var icon = toggleButton.querySelector('.theme-toggle__icon');
|
||||
toggleButton.setAttribute('aria-pressed', normalizedTheme === 'dark' ? 'true' : 'false');
|
||||
toggleButton.setAttribute('aria-label', 'Switch to ' + nextThemeLabel.toLowerCase());
|
||||
if (icon) {
|
||||
icon.classList.remove('theme-toggle__icon--moon', 'theme-toggle__icon--sun');
|
||||
icon.classList.add(normalizedTheme === 'dark' ? 'theme-toggle__icon--sun' : 'theme-toggle__icon--moon');
|
||||
}
|
||||
});
|
||||
|
||||
return normalizedTheme;
|
||||
}
|
||||
|
||||
function initThemeToggle() {
|
||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-theme-toggle]'));
|
||||
var storedTheme = getStoredTheme();
|
||||
var theme = storedTheme || getPreferredTheme();
|
||||
|
||||
applyTheme(theme);
|
||||
|
||||
if (!toggleButtons.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.addEventListener('click', function () {
|
||||
var nextTheme = document.documentElement.dataset.bsTheme === 'dark' ? 'light' : 'dark';
|
||||
setStoredTheme(nextTheme);
|
||||
applyTheme(nextTheme);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function initSidebarToggle() {
|
||||
var body = document.body;
|
||||
var toggleButtons = Array.prototype.slice.call(document.querySelectorAll('[data-sidebar-toggle]'));
|
||||
var backdrop = document.querySelector('[data-sidebar-backdrop]');
|
||||
|
||||
if (!toggleButtons.length || !backdrop) {
|
||||
return;
|
||||
}
|
||||
|
||||
function setSidebarOpen(isOpen) {
|
||||
body.classList.toggle('is-sidebar-open', isOpen);
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.setAttribute('aria-expanded', isOpen ? 'true' : 'false');
|
||||
});
|
||||
}
|
||||
|
||||
function toggleSidebar() {
|
||||
setSidebarOpen(!body.classList.contains('is-sidebar-open'));
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(toggleButtons, function (toggleButton) {
|
||||
toggleButton.addEventListener('click', function () {
|
||||
toggleSidebar();
|
||||
});
|
||||
});
|
||||
|
||||
backdrop.addEventListener('click', function () {
|
||||
setSidebarOpen(false);
|
||||
});
|
||||
|
||||
document.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Escape') {
|
||||
setSidebarOpen(false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
window.getPreferredTheme = getPreferredTheme;
|
||||
window.getStoredTheme = getStoredTheme;
|
||||
window.setStoredTheme = setStoredTheme;
|
||||
window.applyTheme = applyTheme;
|
||||
window.initThemeToggle = initThemeToggle;
|
||||
window.initSidebarToggle = initSidebarToggle;
|
||||
|
||||
initThemeToggle();
|
||||
initSidebarToggle();
|
||||
}());
|
||||
@@ -1,134 +0,0 @@
|
||||
(function () {
|
||||
function getBootstrapToast(toast) {
|
||||
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return window.bootstrap.Toast.getOrCreateInstance(toast, {
|
||||
autohide: true,
|
||||
delay: 4000
|
||||
});
|
||||
}
|
||||
|
||||
function removeToast(toast) {
|
||||
if (toast && toast.parentNode) {
|
||||
toast.parentNode.removeChild(toast);
|
||||
}
|
||||
}
|
||||
|
||||
function dismissToast(toast) {
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.hide();
|
||||
return;
|
||||
}
|
||||
removeToast(toast);
|
||||
}
|
||||
|
||||
function setToastVariant(toast, variant) {
|
||||
if (!toast || !toast.classList) {
|
||||
return;
|
||||
}
|
||||
|
||||
var nextVariant = String(variant || 'info').trim().toLowerCase();
|
||||
var variants = ['primary', 'secondary', 'success', 'danger', 'warning', 'info', 'light', 'dark'];
|
||||
variants.forEach(function (value) {
|
||||
toast.classList.remove('text-bg-' + value);
|
||||
});
|
||||
toast.classList.add('text-bg-' + (variants.indexOf(nextVariant) === -1 ? 'info' : nextVariant));
|
||||
}
|
||||
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
var text = String(message || '').trim();
|
||||
if (/\b(?:unable to|cannot|can't|could not|failed to)\s+delete\b/i.test(text) || /\bdelete\b.*\b(?:before|first)\b/i.test(text) || /\bstill (?:in use|linked|assigned|used)\b/i.test(text)) {
|
||||
return 'danger';
|
||||
}
|
||||
if (/\b(?:already exists|already exist|already taken|duplicate|must be unique|name already exists)\b/i.test(text)) {
|
||||
return 'warning';
|
||||
}
|
||||
return String(fallbackVariant || 'info').trim().toLowerCase() || 'info';
|
||||
}
|
||||
|
||||
function showToast(message, variant) {
|
||||
var text = String(message || '').trim();
|
||||
if (!text) {
|
||||
return;
|
||||
}
|
||||
|
||||
var container = document.getElementById('app-toast-container');
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
var existingToast = document.getElementById('app-toast');
|
||||
if (existingToast) {
|
||||
var existingBody = existingToast.querySelector('.toast-body');
|
||||
if (existingBody) {
|
||||
existingBody.textContent = text;
|
||||
}
|
||||
var nextVariant = getMessageVariant(text, variant);
|
||||
existingToast.setAttribute('data-toast-variant', nextVariant);
|
||||
setToastVariant(existingToast, nextVariant);
|
||||
var existingInstance = getBootstrapToast(existingToast);
|
||||
if (existingInstance) {
|
||||
existingInstance.show();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast align-items-center border-0';
|
||||
toast.id = 'app-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
toast.setAttribute('data-bs-autohide', 'true');
|
||||
toast.setAttribute('data-bs-delay', '4000');
|
||||
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
|
||||
var toastVariant = getMessageVariant(text, variant);
|
||||
toast.setAttribute('data-toast-variant', toastVariant);
|
||||
setToastVariant(toast, toastVariant);
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', function () {
|
||||
removeToast(toast);
|
||||
});
|
||||
|
||||
container.appendChild(toast);
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
function initToast() {
|
||||
var toast = document.getElementById('app-toast');
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
var url = new URL(window.location.href);
|
||||
if (url.searchParams.has('message')) {
|
||||
url.searchParams.delete('message');
|
||||
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||
}
|
||||
} catch (_error) {
|
||||
// ignore URL cleanup failures
|
||||
}
|
||||
|
||||
var existingVariant = String(toast.getAttribute('data-toast-variant') || '').trim().toLowerCase() || getMessageVariant((toast.querySelector('.toast-body') && toast.querySelector('.toast-body').textContent) || '', 'info');
|
||||
setToastVariant(toast, existingVariant);
|
||||
|
||||
var instance = getBootstrapToast(toast);
|
||||
if (instance) {
|
||||
instance.show();
|
||||
}
|
||||
}
|
||||
|
||||
window.dismissToast = dismissToast;
|
||||
window.showToast = showToast;
|
||||
window.initToast = initToast;
|
||||
|
||||
initToast();
|
||||
}());
|
||||
@@ -1,83 +1,11 @@
|
||||
(function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
var webUiHelpers = window.webUiHelpers || {};
|
||||
var escapeHtml = webUiHelpers.escapeHtml;
|
||||
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||
|
||||
function renderClientActionCell(client) {
|
||||
var paused = Boolean(client.paused);
|
||||
@@ -91,7 +19,7 @@
|
||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||
|
||||
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||
}
|
||||
|
||||
function updateClientActionCell(cell, client) {
|
||||
@@ -107,7 +35,7 @@
|
||||
|
||||
var paused = Boolean(client.paused);
|
||||
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
||||
pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||
|
||||
var pauseForm = pauseButton.form;
|
||||
if (pauseForm) {
|
||||
@@ -252,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 [
|
||||
'<tr>',
|
||||
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>',
|
||||
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>',
|
||||
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>',
|
||||
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>',
|
||||
'</tr>'
|
||||
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
|
||||
'<div class="dashboard-screen-tile-top">',
|
||||
'<div class="dashboard-screen-tile-text">',
|
||||
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
|
||||
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
|
||||
'</div>',
|
||||
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
|
||||
'</div>',
|
||||
'<dl class="dashboard-screen-meta">',
|
||||
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
|
||||
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
|
||||
'</dl>',
|
||||
'</article>'
|
||||
].join('');
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -352,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 = '<div class="empty dashboard-screen-empty">No screens yet.</div>';
|
||||
return;
|
||||
}
|
||||
if (!state.screens.length) {
|
||||
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
|
||||
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 = '<i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
|
||||
setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
|
||||
if (pauseInput) {
|
||||
pauseInput.value = allPaused ? 'false' : 'true';
|
||||
}
|
||||
if (pauseForm) {
|
||||
pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?');
|
||||
}
|
||||
pauseButton.setAttribute('aria-label', pauseLabel);
|
||||
}
|
||||
|
||||
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
||||
var blackoutForm = blackoutButton.form;
|
||||
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
||||
@@ -403,7 +379,7 @@
|
||||
return;
|
||||
}
|
||||
updateStats(state);
|
||||
updateScreenTable(state);
|
||||
updateScreenGrid(state);
|
||||
updateClientTable(state);
|
||||
updateDashboardQuickActions(state);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,26 @@
|
||||
(function () {
|
||||
(function () {
|
||||
// Shared constants used across the playlist schedule editor.
|
||||
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||
var VIDEO_DURATION_LABEL = 'Use video duration';
|
||||
var VIDEO_DURATION_ACTIVE_LABEL = 'Use Video Duration';
|
||||
var VIDEO_DURATION_LOADING_LABEL = 'Loading...';
|
||||
var PICKER_THUMB_EMPTY_LABEL = 'No thumbnail';
|
||||
var PICKER_ASSIGNED_BADGE_LABEL = 'Already in playlist';
|
||||
var PICKER_CARD_CLASS = 'playlist-slide-picker-card';
|
||||
var PICKER_MEDIA_CLASS = 'playlist-slide-picker-media';
|
||||
var PICKER_TITLE_CLASS = 'playlist-slide-picker-title';
|
||||
var PICKER_CHECK_CLASS = 'playlist-slide-picker-check bi bi-check2-circle';
|
||||
var PICKER_BADGE_CLASS = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||
var SCHEDULE_ROW_FIELD_NAMES = {
|
||||
mode: '[name="schedule_mode[]"]',
|
||||
startDatetime: '[name="schedule_start_datetime[]"]',
|
||||
endDatetime: '[name="schedule_end_datetime[]"]',
|
||||
startTime: '[name="schedule_start_time[]"]',
|
||||
endTime: '[name="schedule_end_time[]"]',
|
||||
daysJson: '[name="schedule_days_json[]"]'
|
||||
};
|
||||
|
||||
// Schedule helpers.
|
||||
function formatDays(daysValue) {
|
||||
var days = [];
|
||||
if (Array.isArray(daysValue)) {
|
||||
@@ -21,6 +41,192 @@
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function getScheduleRowFields(row) {
|
||||
return {
|
||||
mode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.mode) : null,
|
||||
startDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startDatetime) : null,
|
||||
endDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endDatetime) : null,
|
||||
startTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startTime) : null,
|
||||
endTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endTime) : null,
|
||||
daysJson: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.daysJson) : null
|
||||
};
|
||||
}
|
||||
|
||||
// Video duration helpers.
|
||||
function buildVideoDurationButtonMarkup(isPressed, enabled) {
|
||||
if (!enabled) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (isPressed ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '">' + (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL) + '</button>';
|
||||
}
|
||||
|
||||
function syncVideoDurationButtonState(button, isPressed, label) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.toggle('active', Boolean(isPressed));
|
||||
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
|
||||
button.textContent = label || (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL);
|
||||
}
|
||||
|
||||
// Slide picker helpers.
|
||||
function getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount) {
|
||||
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||
return query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||
}
|
||||
|
||||
return query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||
}
|
||||
|
||||
function updateSlidePickerCardState(card, isAssigned, isCanvasMismatch) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||
card.disabled = isAssigned || isCanvasMismatch;
|
||||
card.classList.toggle('is-assigned', isAssigned);
|
||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||
card.classList.toggle('is-hidden', false);
|
||||
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function createElementWithClass(tagName, className, textContent) {
|
||||
var element = document.createElement(tagName);
|
||||
|
||||
if (className) {
|
||||
element.className = className;
|
||||
}
|
||||
if (textContent !== undefined) {
|
||||
element.textContent = textContent;
|
||||
}
|
||||
|
||||
return element;
|
||||
}
|
||||
|
||||
function buildSlidePickerThumbPlaceholder() {
|
||||
var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder');
|
||||
var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon');
|
||||
var label = createElementWithClass('span', 'playlist-slide-picker-thumb-placeholder-label', PICKER_THUMB_EMPTY_LABEL);
|
||||
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
placeholder.appendChild(icon);
|
||||
placeholder.appendChild(label);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
function buildSlidePickerThumbImage(slide) {
|
||||
var image = createElementWithClass('img', 'playlist-slide-picker-thumb-image');
|
||||
|
||||
image.loading = 'lazy';
|
||||
image.alt = String(slide.title || 'Slide');
|
||||
image.src = String(slide.thumbnail_path || '');
|
||||
image.addEventListener('error', function () {
|
||||
if (image.parentNode) {
|
||||
image.parentNode.replaceChild(buildSlidePickerThumbPlaceholder(), image);
|
||||
}
|
||||
});
|
||||
|
||||
return image;
|
||||
}
|
||||
|
||||
function buildSlidePickerMedia(slide) {
|
||||
var media = createElementWithClass('div', PICKER_MEDIA_CLASS);
|
||||
var check = createElementWithClass('span', PICKER_CHECK_CLASS);
|
||||
var badge = createElementWithClass('span', PICKER_BADGE_CLASS, PICKER_ASSIGNED_BADGE_LABEL);
|
||||
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
|
||||
media.appendChild(slide && slide.thumbnail_path ? buildSlidePickerThumbImage(slide) : buildSlidePickerThumbPlaceholder());
|
||||
media.appendChild(check);
|
||||
media.appendChild(badge);
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
// Playlist row helpers.
|
||||
function getPlaylistSlideRow(target) {
|
||||
return target && target.closest ? target.closest('tr[data-playlist-slide-row]') : null;
|
||||
}
|
||||
|
||||
function getPlaylistSlideRowKey(row) {
|
||||
return String(row && row.getAttribute('data-row-key') || '');
|
||||
}
|
||||
|
||||
function isDurationControl(target) {
|
||||
return Boolean(target && target.closest && target.closest('.playlist-use-video-duration'));
|
||||
}
|
||||
|
||||
function getDurationPointerState(target, type) {
|
||||
var row = getPlaylistSlideRow(target);
|
||||
var pointerType = type === 'button' ? 'button' : 'input';
|
||||
|
||||
if (!row) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
type: pointerType,
|
||||
rowKey: getPlaylistSlideRowKey(row)
|
||||
};
|
||||
}
|
||||
|
||||
function buildPlaylistOrderCellMarkup() {
|
||||
return '' +
|
||||
'<td class="playlist-order-cell" data-label="Order">' +
|
||||
'<div class="playlist-order-cell-inner">' +
|
||||
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
|
||||
'<span class="playlist-order-number"></span>' +
|
||||
'</div>' +
|
||||
'</td>';
|
||||
}
|
||||
|
||||
function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) {
|
||||
return '' +
|
||||
'<td data-label="Slide">' +
|
||||
'<div class="playlist-slide-cell">' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
|
||||
'</td>';
|
||||
}
|
||||
|
||||
function buildPlaylistScheduleCellMarkup(values) {
|
||||
return '' +
|
||||
'<td>' +
|
||||
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
||||
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
||||
'</td>';
|
||||
}
|
||||
|
||||
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
|
||||
return '' +
|
||||
'<td><div class="playlist-duration-field">' +
|
||||
'<input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
|
||||
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
|
||||
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
|
||||
durationActionMarkup +
|
||||
'</div></td>';
|
||||
}
|
||||
|
||||
function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
|
||||
return '' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||
'</div></td>';
|
||||
}
|
||||
|
||||
// Schedule modal wiring.
|
||||
function initPlaylistScheduleModal() {
|
||||
var dialog = document.getElementById('slide-schedule-dialog');
|
||||
var content = document.getElementById('slide-schedule-content');
|
||||
@@ -38,14 +244,9 @@
|
||||
|
||||
function collectScheduleParams(row, rowKey) {
|
||||
var params = new URLSearchParams();
|
||||
var scheduleFields = [
|
||||
'schedule_mode[]',
|
||||
'schedule_start_datetime[]',
|
||||
'schedule_end_datetime[]',
|
||||
'schedule_start_time[]',
|
||||
'schedule_end_time[]',
|
||||
'schedule_days_json[]'
|
||||
];
|
||||
var scheduleFields = Object.keys(SCHEDULE_ROW_FIELD_NAMES).map(function (key) {
|
||||
return SCHEDULE_ROW_FIELD_NAMES[key].replace(/^\[name="|"\]$/g, '');
|
||||
});
|
||||
|
||||
params.set('row_key', String(rowKey || ''));
|
||||
scheduleFields.forEach(function (fieldName) {
|
||||
@@ -113,6 +314,7 @@
|
||||
|
||||
}
|
||||
|
||||
// Schedule form behavior.
|
||||
function scheduleSummary(values) {
|
||||
var mode = String(values.schedule_mode || 'always');
|
||||
if (mode === 'dates') {
|
||||
@@ -151,6 +353,25 @@
|
||||
var rowKeyInput = form.querySelector('[name="row_key"]');
|
||||
var dayCheckboxes = form.querySelectorAll('[name="schedule_days"]');
|
||||
|
||||
function getDayButton(checkbox) {
|
||||
return checkbox && checkbox.id ? form.querySelector('label[for="' + checkbox.id + '"]') : null;
|
||||
}
|
||||
|
||||
function syncDayButtonState(checkbox) {
|
||||
var button = getDayButton(checkbox);
|
||||
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
button.classList.toggle('active', Boolean(checkbox.checked));
|
||||
button.setAttribute('aria-pressed', checkbox.checked ? 'true' : 'false');
|
||||
}
|
||||
|
||||
function syncAllDayButtonStates() {
|
||||
Array.prototype.forEach.call(dayCheckboxes, syncDayButtonState);
|
||||
}
|
||||
|
||||
function clearScheduleValidity() {
|
||||
[startDateInput, endDateInput, startTimeInput, endTimeInput].forEach(function (input) {
|
||||
if (input) {
|
||||
@@ -210,14 +431,23 @@
|
||||
}
|
||||
});
|
||||
Array.prototype.forEach.call(dayCheckboxes, function (checkbox) {
|
||||
checkbox.addEventListener('change', clearScheduleValidity);
|
||||
checkbox.addEventListener('change', function () {
|
||||
syncDayButtonState(checkbox);
|
||||
clearScheduleValidity();
|
||||
});
|
||||
|
||||
var button = getDayButton(checkbox);
|
||||
if (button) {
|
||||
button.addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
checkbox.checked = !checkbox.checked;
|
||||
checkbox.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
checkbox.focus();
|
||||
});
|
||||
}
|
||||
});
|
||||
select.addEventListener('change', clearScheduleValidity);
|
||||
|
||||
function notifyParentResize() {
|
||||
return;
|
||||
}
|
||||
|
||||
function updateVisibility() {
|
||||
var mode = select.value;
|
||||
if (datesPanel) {
|
||||
@@ -234,13 +464,11 @@
|
||||
endTimeInput.value = DEFAULT_END_TIME;
|
||||
}
|
||||
}
|
||||
window.requestAnimationFrame(function () {
|
||||
notifyParentResize();
|
||||
});
|
||||
}
|
||||
|
||||
select.addEventListener('change', updateVisibility);
|
||||
updateVisibility();
|
||||
syncAllDayButtonStates();
|
||||
|
||||
form.addEventListener('submit', function (event) {
|
||||
event.preventDefault();
|
||||
@@ -302,6 +530,7 @@
|
||||
});
|
||||
}
|
||||
|
||||
// Playlist editor behavior.
|
||||
function initPlaylistEditStaging() {
|
||||
var tbody = document.getElementById('playlist-items-body');
|
||||
var form = document.getElementById('playlist-edit-form');
|
||||
@@ -317,6 +546,9 @@
|
||||
var slidePickerCards = [];
|
||||
var slidePickerSelection = new Set();
|
||||
var slidePickerSlides = [];
|
||||
var videoDurationCache = Object.create(null);
|
||||
var lastDurationPointerDown = null;
|
||||
var lastDurationPointerUp = null;
|
||||
|
||||
if (!tbody || !form) {
|
||||
return;
|
||||
@@ -331,13 +563,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
function getModalInstance() {
|
||||
if (!window.pulseModal) {
|
||||
return null;
|
||||
}
|
||||
return window.pulseModal.getOrCreate(addSlideModal);
|
||||
}
|
||||
|
||||
// Selection state helpers for the picker modal.
|
||||
function setCardSelected(card, selected) {
|
||||
if (!card) {
|
||||
return;
|
||||
@@ -370,50 +596,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
function createPickerThumb(slide) {
|
||||
function createPlaceholder() {
|
||||
var placeholder = document.createElement('div');
|
||||
var icon = document.createElement('i');
|
||||
var label = document.createElement('span');
|
||||
|
||||
placeholder.className = 'playlist-slide-picker-thumb-placeholder';
|
||||
icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon';
|
||||
icon.setAttribute('aria-hidden', 'true');
|
||||
label.className = 'playlist-slide-picker-thumb-placeholder-label';
|
||||
label.textContent = 'No thumbnail';
|
||||
|
||||
placeholder.appendChild(icon);
|
||||
placeholder.appendChild(label);
|
||||
return placeholder;
|
||||
}
|
||||
|
||||
if (slide && slide.thumbnail_path) {
|
||||
var image = document.createElement('img');
|
||||
image.className = 'playlist-slide-picker-thumb-image';
|
||||
image.loading = 'lazy';
|
||||
image.alt = String(slide.title || 'Slide');
|
||||
image.src = String(slide.thumbnail_path || '');
|
||||
image.addEventListener('error', function () {
|
||||
if (image.parentNode) {
|
||||
image.parentNode.replaceChild(createPlaceholder(), image);
|
||||
}
|
||||
});
|
||||
return image;
|
||||
}
|
||||
|
||||
return createPlaceholder();
|
||||
}
|
||||
|
||||
// Picker card rendering.
|
||||
function createPickerCard(slide) {
|
||||
var button = document.createElement('button');
|
||||
var media = document.createElement('div');
|
||||
var title = document.createElement('div');
|
||||
var check = document.createElement('span');
|
||||
var badge = document.createElement('span');
|
||||
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
||||
|
||||
button.type = 'button';
|
||||
button.className = 'playlist-slide-picker-card';
|
||||
button.className = PICKER_CARD_CLASS;
|
||||
button.setAttribute('aria-pressed', 'false');
|
||||
button.setAttribute('data-slide-id', String(slide.id || ''));
|
||||
button.setAttribute('data-search-text', searchText);
|
||||
@@ -424,18 +613,9 @@
|
||||
button.classList.add('is-assigned');
|
||||
}
|
||||
|
||||
media.className = 'playlist-slide-picker-media';
|
||||
media.appendChild(createPickerThumb(slide));
|
||||
check.className = 'playlist-slide-picker-check bi bi-check2-circle';
|
||||
check.setAttribute('aria-hidden', 'true');
|
||||
media.appendChild(check);
|
||||
|
||||
badge.className = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||
badge.textContent = 'Already in playlist';
|
||||
badge.setAttribute('aria-hidden', 'true');
|
||||
media.appendChild(badge);
|
||||
|
||||
title.className = 'playlist-slide-picker-title';
|
||||
var media = buildSlidePickerMedia(slide);
|
||||
var title = document.createElement('div');
|
||||
title.className = PICKER_TITLE_CLASS;
|
||||
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
||||
|
||||
button.appendChild(media);
|
||||
@@ -444,6 +624,7 @@
|
||||
return button;
|
||||
}
|
||||
|
||||
// Picker filtering and modal controls.
|
||||
function setShowAssignedState(showAssignedSlides) {
|
||||
if (!addSlideShowAssigned) {
|
||||
return;
|
||||
@@ -489,11 +670,7 @@
|
||||
});
|
||||
|
||||
if (addSlideEmpty) {
|
||||
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||
addSlideEmpty.textContent = query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||
} else {
|
||||
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||
}
|
||||
addSlideEmpty.textContent = getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount);
|
||||
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||
}
|
||||
|
||||
@@ -547,6 +724,11 @@
|
||||
canvas_width: slide.canvas_width,
|
||||
canvas_height: slide.canvas_height,
|
||||
canvas_signature: String(slide.canvasSignature || ''),
|
||||
showVideoDurationButton: slide.showVideoDurationButton,
|
||||
videoSourcePath: slide.videoSourcePath,
|
||||
videoDurationSeconds: slide.videoDurationSeconds,
|
||||
media_type: slide.media_type,
|
||||
media_path: slide.media_path,
|
||||
title: slide.title || 'Slide',
|
||||
duration_seconds: 10,
|
||||
schedule_mode: 'always',
|
||||
@@ -607,24 +789,19 @@
|
||||
}
|
||||
|
||||
function scheduleSummaryForRow(row) {
|
||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
||||
var mode = String(modeInput ? modeInput.value : 'always');
|
||||
var days = formatDays(daysJson && daysJson.value ? daysJson.value : '[]');
|
||||
var fields = getScheduleRowFields(row);
|
||||
var mode = String(fields.mode ? fields.mode.value : 'always');
|
||||
var days = formatDays(fields.daysJson && fields.daysJson.value ? fields.daysJson.value : '[]');
|
||||
|
||||
if (mode === 'dates') {
|
||||
if (startDatetime && endDatetime && startDatetime.value && endDatetime.value) {
|
||||
return 'Dates: ' + startDatetime.value.replace('T', ' ') + ' to ' + endDatetime.value.replace('T', ' ');
|
||||
if (fields.startDatetime && fields.endDatetime && fields.startDatetime.value && fields.endDatetime.value) {
|
||||
return 'Dates: ' + fields.startDatetime.value.replace('T', ' ') + ' to ' + fields.endDatetime.value.replace('T', ' ');
|
||||
}
|
||||
return 'Dates: not set';
|
||||
}
|
||||
if (mode === 'times') {
|
||||
if (days && startTime && endTime && startTime.value && endTime.value) {
|
||||
return 'Times: ' + days + ' ' + startTime.value.slice(0, 5) + '-' + endTime.value.slice(0, 5);
|
||||
if (days && fields.startTime && fields.endTime && fields.startTime.value && fields.endTime.value) {
|
||||
return 'Times: ' + days + ' ' + fields.startTime.value.slice(0, 5) + '-' + fields.endTime.value.slice(0, 5);
|
||||
}
|
||||
return 'Times: not set';
|
||||
}
|
||||
@@ -662,14 +839,7 @@
|
||||
|
||||
slide.isAssigned = isAssigned;
|
||||
|
||||
if (card) {
|
||||
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||
card.disabled = isAssigned || isCanvasMismatch;
|
||||
card.classList.toggle('is-assigned', isAssigned);
|
||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||
card.classList.toggle('is-hidden', false);
|
||||
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
||||
}
|
||||
updateSlidePickerCardState(card, isAssigned, isCanvasMismatch);
|
||||
if (isAssigned) {
|
||||
slidePickerSelection.delete(slideId);
|
||||
if (card) {
|
||||
@@ -737,31 +907,26 @@
|
||||
}
|
||||
|
||||
function setRowSchedule(row, values) {
|
||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
||||
var fields = getScheduleRowFields(row);
|
||||
var summary = row.querySelector('.playlist-schedule-summary');
|
||||
|
||||
if (modeInput) {
|
||||
modeInput.value = values.schedule_mode || 'always';
|
||||
if (fields.mode) {
|
||||
fields.mode.value = values.schedule_mode || 'always';
|
||||
}
|
||||
if (startDatetime) {
|
||||
startDatetime.value = values.schedule_start_datetime || '';
|
||||
if (fields.startDatetime) {
|
||||
fields.startDatetime.value = values.schedule_start_datetime || '';
|
||||
}
|
||||
if (endDatetime) {
|
||||
endDatetime.value = values.schedule_end_datetime || '';
|
||||
if (fields.endDatetime) {
|
||||
fields.endDatetime.value = values.schedule_end_datetime || '';
|
||||
}
|
||||
if (startTime) {
|
||||
startTime.value = values.schedule_start_time || '';
|
||||
if (fields.startTime) {
|
||||
fields.startTime.value = values.schedule_start_time || '';
|
||||
}
|
||||
if (endTime) {
|
||||
endTime.value = values.schedule_end_time || '';
|
||||
if (fields.endTime) {
|
||||
fields.endTime.value = values.schedule_end_time || '';
|
||||
}
|
||||
if (daysJson) {
|
||||
daysJson.value = values.schedule_days_json || '[]';
|
||||
if (fields.daysJson) {
|
||||
fields.daysJson.value = values.schedule_days_json || '[]';
|
||||
}
|
||||
if (summary) {
|
||||
summary.textContent = values.summary || scheduleSummaryForRow(row);
|
||||
@@ -786,40 +951,217 @@
|
||||
row.setAttribute('data-row-key', rowKey);
|
||||
row.setAttribute('data-slide-id', String(values.slide_id));
|
||||
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
|
||||
row.setAttribute('data-media-type', String(values.media_type || ''));
|
||||
row.setAttribute('data-media-path', String(values.media_path || ''));
|
||||
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
|
||||
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
|
||||
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
|
||||
var durationActionMarkup = buildVideoDurationButtonMarkup(
|
||||
Boolean(values.useVideoDuration),
|
||||
Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
|
||||
);
|
||||
row.innerHTML = '' +
|
||||
'<td class="playlist-order-cell" data-label="Order">' +
|
||||
'<div class="playlist-order-cell-inner">' +
|
||||
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
|
||||
'<span class="playlist-order-number"></span>' +
|
||||
'</div>' +
|
||||
'</td>' +
|
||||
'<td data-label="Slide">' +
|
||||
'<div class="playlist-slide-cell">' +
|
||||
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
||||
'<td>' +
|
||||
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
||||
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
||||
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
||||
'</td>' +
|
||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" /></td>' +
|
||||
'<td><div class="actions playlist-item-actions">' +
|
||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||
'</div></td>';
|
||||
buildPlaylistOrderCellMarkup() +
|
||||
buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) +
|
||||
buildPlaylistScheduleCellMarkup(values) +
|
||||
buildPlaylistDurationCellMarkup(values, durationActionMarkup) +
|
||||
buildPlaylistActionsCellMarkup(playlistId, rowKey);
|
||||
return row;
|
||||
}
|
||||
|
||||
function loadVideoDuration(mediaPath) {
|
||||
var cacheKey = String(mediaPath || '').trim();
|
||||
var cachedPromise;
|
||||
|
||||
if (!cacheKey) {
|
||||
return Promise.reject(new Error('No video source is available for this slide.'));
|
||||
}
|
||||
|
||||
cachedPromise = videoDurationCache[cacheKey];
|
||||
if (cachedPromise) {
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
cachedPromise = new Promise(function (resolve, reject) {
|
||||
var video = document.createElement('video');
|
||||
var timeoutId = window.setTimeout(function () {
|
||||
cleanup();
|
||||
reject(new Error('Timed out loading the video metadata.'));
|
||||
}, 15000);
|
||||
|
||||
function cleanup() {
|
||||
window.clearTimeout(timeoutId);
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
if (video.parentNode) {
|
||||
video.parentNode.removeChild(video);
|
||||
}
|
||||
}
|
||||
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('muted', '');
|
||||
video.style.position = 'absolute';
|
||||
video.style.left = '-9999px';
|
||||
video.style.top = '0';
|
||||
video.style.width = '1px';
|
||||
video.style.height = '1px';
|
||||
video.style.opacity = '0';
|
||||
video.style.pointerEvents = 'none';
|
||||
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
var duration = Number(video.duration);
|
||||
cleanup();
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
resolve(Math.round(duration * 1000) / 1000);
|
||||
} else {
|
||||
reject(new Error('The video duration could not be read.'));
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
video.addEventListener('error', function () {
|
||||
cleanup();
|
||||
reject(new Error('The video duration could not be read.'));
|
||||
}, { once: true });
|
||||
|
||||
video.addEventListener('abort', function () {
|
||||
cleanup();
|
||||
reject(new Error('The video duration could not be read.'));
|
||||
}, { once: true });
|
||||
|
||||
document.body.appendChild(video);
|
||||
video.src = cacheKey;
|
||||
video.load();
|
||||
}).catch(function (error) {
|
||||
delete videoDurationCache[cacheKey];
|
||||
throw error;
|
||||
});
|
||||
|
||||
videoDurationCache[cacheKey] = cachedPromise;
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
function setVideoDurationToggleState(row, button, isPressed) {
|
||||
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
|
||||
var flag = row ? row.querySelector('[name="use_video_duration[]"]') : null;
|
||||
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
|
||||
|
||||
if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) {
|
||||
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
|
||||
}
|
||||
|
||||
syncVideoDurationButtonState(button, isPressed);
|
||||
|
||||
if (input) {
|
||||
if (!isPressed && row) {
|
||||
var previousValue = String(row.getAttribute('data-video-duration-previous-value') || '').trim();
|
||||
if (previousValue) {
|
||||
input.value = previousValue;
|
||||
}
|
||||
row.removeAttribute('data-video-duration-previous-value');
|
||||
}
|
||||
input.disabled = Boolean(isPressed);
|
||||
input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed));
|
||||
}
|
||||
|
||||
if (flag) {
|
||||
flag.value = isPressed ? '1' : '0';
|
||||
}
|
||||
|
||||
if (row) {
|
||||
row.setAttribute('data-use-video-duration', isPressed ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (!row || !input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (isPressed) {
|
||||
if (!mirror) {
|
||||
mirror = document.createElement('input');
|
||||
mirror.type = 'hidden';
|
||||
mirror.setAttribute('data-video-duration-mirror', '');
|
||||
input.parentNode.insertBefore(mirror, input.nextSibling);
|
||||
}
|
||||
mirror.name = input.name;
|
||||
mirror.value = input.value;
|
||||
if (input.getAttribute('form')) {
|
||||
mirror.setAttribute('form', input.getAttribute('form'));
|
||||
}
|
||||
} else if (mirror && mirror.parentNode) {
|
||||
mirror.parentNode.removeChild(mirror);
|
||||
}
|
||||
}
|
||||
|
||||
async function applyVideoDurationToRow(row, button) {
|
||||
var mediaType = String(row && row.getAttribute('data-media-type') || '').trim().toLowerCase();
|
||||
var mediaPath = String(row && (row.getAttribute('data-video-source-path') || row.getAttribute('data-media-path')) || '').trim();
|
||||
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
|
||||
var isVideoRow = mediaType === 'video' || Boolean(mediaPath);
|
||||
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
|
||||
|
||||
if (!isVideoRow || !input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button && button.getAttribute('aria-pressed') === 'true') {
|
||||
setVideoDurationToggleState(row, button, false);
|
||||
return;
|
||||
}
|
||||
|
||||
setVideoDurationToggleState(row, button, true);
|
||||
|
||||
if (Number.isFinite(storedDuration) && storedDuration > 0) {
|
||||
input.value = String(storedDuration);
|
||||
var existingMirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
|
||||
if (existingMirror) {
|
||||
existingMirror.value = input.value;
|
||||
}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (button) {
|
||||
button.disabled = true;
|
||||
button.textContent = VIDEO_DURATION_LOADING_LABEL;
|
||||
}
|
||||
|
||||
try {
|
||||
var durationSeconds = await loadVideoDuration(mediaPath);
|
||||
if (row) {
|
||||
row.setAttribute('data-video-duration-seconds', String(durationSeconds));
|
||||
}
|
||||
input.value = String(durationSeconds);
|
||||
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
|
||||
if (mirror) {
|
||||
mirror.value = input.value;
|
||||
}
|
||||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
} catch (error) {
|
||||
setVideoDurationToggleState(row, button, false);
|
||||
alert(error && error.message ? error.message : 'Unable to read the video duration.');
|
||||
} finally {
|
||||
if (button) {
|
||||
button.disabled = false;
|
||||
syncVideoDurationButtonState(button, button.getAttribute('aria-pressed') === 'true');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
var removeButton = event.target.closest('[data-playlist-remove-row]');
|
||||
var scheduleButton = event.target.closest('[data-schedule-config]');
|
||||
var row = event.target.closest('tr[data-playlist-slide-row]');
|
||||
var durationButton = event.target.closest('[data-use-video-duration]');
|
||||
var row = getPlaylistSlideRow(event.target);
|
||||
|
||||
if (!row) {
|
||||
return;
|
||||
@@ -835,8 +1177,92 @@
|
||||
if (scheduleButton) {
|
||||
event.preventDefault();
|
||||
if (typeof window.openScheduleModal === 'function') {
|
||||
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || ''));
|
||||
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(getPlaylistSlideRowKey(row)));
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationButton) {
|
||||
var rowKey = getPlaylistSlideRowKey(row);
|
||||
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
|
||||
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
|
||||
|
||||
if (event.detail > 0 && (pressedOnInput || !releasedOnButton)) {
|
||||
lastDurationPointerDown = null;
|
||||
lastDurationPointerUp = null;
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
void applyVideoDurationToRow(row, durationButton);
|
||||
}
|
||||
|
||||
lastDurationPointerDown = null;
|
||||
lastDurationPointerUp = null;
|
||||
});
|
||||
|
||||
tbody.addEventListener('mousedown', function (event) {
|
||||
var row = getPlaylistSlideRow(event.target);
|
||||
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
|
||||
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
||||
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
||||
|
||||
if (row && !dragHandle) {
|
||||
event.stopPropagation();
|
||||
}
|
||||
|
||||
if (durationInput) {
|
||||
lastDurationPointerDown = getDurationPointerState(event.target, 'input');
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationToggle) {
|
||||
lastDurationPointerDown = getDurationPointerState(durationToggle, 'button');
|
||||
return;
|
||||
}
|
||||
|
||||
lastDurationPointerDown = null;
|
||||
});
|
||||
|
||||
tbody.addEventListener('mouseup', function (event) {
|
||||
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
||||
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
||||
var row = getPlaylistSlideRow(event.target);
|
||||
|
||||
if (durationInput) {
|
||||
lastDurationPointerUp = getDurationPointerState(row, 'input');
|
||||
return;
|
||||
}
|
||||
|
||||
if (durationToggle) {
|
||||
lastDurationPointerUp = getDurationPointerState(row, 'button');
|
||||
return;
|
||||
}
|
||||
|
||||
lastDurationPointerUp = null;
|
||||
});
|
||||
|
||||
tbody.addEventListener('input', function (event) {
|
||||
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
|
||||
var editedRow = getPlaylistSlideRow(event.target);
|
||||
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
|
||||
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
|
||||
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
|
||||
|
||||
if (editedRow) {
|
||||
editedRow.setAttribute('data-use-video-duration', useVideoDuration ? 'true' : 'false');
|
||||
}
|
||||
|
||||
if (useVideoDuration && editedMirror) {
|
||||
editedMirror.value = event.target.value;
|
||||
}
|
||||
|
||||
if (editedMirror && !useVideoDuration) {
|
||||
editedMirror.parentNode.removeChild(editedMirror);
|
||||
}
|
||||
markPlaylistDirty();
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -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') {
|
||||
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);
|
||||
}());
|
||||
File diff suppressed because it is too large
Load Diff
@@ -58,6 +58,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||
var editorInstances = new Map();
|
||||
var submitting = false;
|
||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||
var uploadMaxLabel = '100 MB';
|
||||
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||
var uploadVideoMaxLabel = '1 GB';
|
||||
var videoDurationCache = Object.create(null);
|
||||
var previewRenderFrame = 0;
|
||||
var previewPopupWindow = null;
|
||||
var previewPopupRenderFrame = 0;
|
||||
@@ -253,6 +258,12 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return current && current.value !== undefined ? current.value : '';
|
||||
}
|
||||
|
||||
function getCurrentRegionVideoDuration(region) {
|
||||
var current = existingContent[region.region_key] || {};
|
||||
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000;
|
||||
return Number.isFinite(duration) && duration > 0 ? String(duration) : '';
|
||||
}
|
||||
|
||||
function normalizeEditorData(value) {
|
||||
return String(value || '');
|
||||
}
|
||||
@@ -456,6 +467,47 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||
}
|
||||
|
||||
function startPreviewVideoPlayback(root) {
|
||||
if (!root) {
|
||||
return;
|
||||
}
|
||||
|
||||
var videos = root.querySelectorAll('video.slide-preview-video');
|
||||
Array.prototype.forEach.call(videos, function (video) {
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
|
||||
video.autoplay = true;
|
||||
video.loop = true;
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
|
||||
function tryPlay() {
|
||||
try {
|
||||
video.load();
|
||||
} catch (_error) {
|
||||
// Ignore load failures and try to play anyway.
|
||||
}
|
||||
|
||||
var playPromise = video.play && video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (video.readyState >= 2) {
|
||||
tryPlay();
|
||||
return;
|
||||
}
|
||||
|
||||
video.addEventListener('canplay', tryPlay, { once: true });
|
||||
video.addEventListener('loadedmetadata', tryPlay, { once: true });
|
||||
});
|
||||
}
|
||||
|
||||
function destroyEditors() {
|
||||
editorInstances.forEach(function (editor) {
|
||||
if (editor && typeof editor.destroy === 'function') {
|
||||
@@ -765,7 +817,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
}
|
||||
|
||||
function clearImagePreviewUrls() {
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
@@ -773,18 +825,370 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
});
|
||||
}
|
||||
|
||||
function getRegionMediaType(card) {
|
||||
if (card && card.dataset && String(card.dataset.regionMediaType || '').trim()) {
|
||||
return String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
|
||||
}
|
||||
|
||||
var regionTypeInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_type[]"]') : null;
|
||||
return regionTypeInput && String(regionTypeInput.value || '').trim() === 'video' ? 'video' : 'image';
|
||||
}
|
||||
|
||||
function getRegionMediaPrefix(mediaType) {
|
||||
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
|
||||
}
|
||||
|
||||
function getRegionExistingMediaPrefix(mediaType) {
|
||||
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
|
||||
}
|
||||
|
||||
function getRegionExistingVideoDurationName(regionId) {
|
||||
return 'existing_region_video_duration_' + regionId;
|
||||
}
|
||||
|
||||
function getRegionVideoDurationHiddenInput(regionId) {
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
return card ? card.querySelector('input[type="hidden"][name="' + getRegionExistingVideoDurationName(regionId) + '"]') : null;
|
||||
}
|
||||
|
||||
function getRegionUploadZone(input) {
|
||||
return input ? input.closest('[data-region-upload-zone]') : null;
|
||||
}
|
||||
|
||||
function getRegionUploadHiddenValue(input) {
|
||||
var mediaType = input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
|
||||
var regionId = input && input.name ? input.name.replace(/^region_(?:image|video)_/, '') : '';
|
||||
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||
return hidden ? hidden.value : '';
|
||||
}
|
||||
|
||||
function getRegionUploadProgressNode(zone) {
|
||||
return zone ? zone.querySelector('[data-region-upload-progress]') : null;
|
||||
}
|
||||
|
||||
function getRegionUploadProgressBar(zone) {
|
||||
return zone ? zone.querySelector('[data-region-upload-progress-bar]') : null;
|
||||
}
|
||||
|
||||
function getRegionUploadProgressText(zone) {
|
||||
return zone ? zone.querySelector('[data-region-upload-progress-text]') : null;
|
||||
}
|
||||
|
||||
function setRegionUploadProgress(zone, percent, text, isIndeterminate) {
|
||||
var progress = getRegionUploadProgressNode(zone);
|
||||
var bar = getRegionUploadProgressBar(zone);
|
||||
var progressText = getRegionUploadProgressText(zone);
|
||||
var normalizedPercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
|
||||
|
||||
if (!progress || !bar) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (progressText) {
|
||||
progressText.textContent = text || 'Uploading...';
|
||||
}
|
||||
|
||||
progress.hidden = false;
|
||||
bar.style.width = (isIndeterminate ? 100 : normalizedPercent) + '%';
|
||||
bar.textContent = isIndeterminate ? 'Uploading...' : normalizedPercent + '%';
|
||||
bar.setAttribute('aria-valuenow', String(isIndeterminate ? 100 : normalizedPercent));
|
||||
bar.classList.toggle('progress-bar-striped', Boolean(isIndeterminate));
|
||||
bar.classList.toggle('progress-bar-animated', Boolean(isIndeterminate));
|
||||
}
|
||||
|
||||
function setRegionUploadState(input, isUploading, percent, text, isIndeterminate) {
|
||||
var zone = getRegionUploadZone(input);
|
||||
var progress = getRegionUploadProgressNode(zone);
|
||||
|
||||
if (!zone || !progress) {
|
||||
return;
|
||||
}
|
||||
|
||||
zone.classList.toggle('is-uploading', Boolean(isUploading));
|
||||
zone.setAttribute('aria-busy', isUploading ? 'true' : 'false');
|
||||
input.disabled = Boolean(isUploading);
|
||||
|
||||
if (isUploading) {
|
||||
setRegionUploadProgress(zone, percent, text, isIndeterminate);
|
||||
return;
|
||||
}
|
||||
|
||||
progress.hidden = true;
|
||||
if (input) {
|
||||
input.disabled = false;
|
||||
}
|
||||
}
|
||||
|
||||
function isRegionImageFileWithinLimit(file) {
|
||||
return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes;
|
||||
}
|
||||
|
||||
function getRegionMediaTypeFromInput(input) {
|
||||
return input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
|
||||
}
|
||||
|
||||
function getRegionMediaUploadLimitBytes(mediaType) {
|
||||
return String(mediaType || '').trim() === 'video' ? uploadVideoMaxBytes : uploadMaxBytes;
|
||||
}
|
||||
|
||||
function getRegionMediaUploadLimitLabel(mediaType) {
|
||||
return String(mediaType || '').trim() === 'video' ? uploadVideoMaxLabel : uploadMaxLabel;
|
||||
}
|
||||
|
||||
function isRegionMediaFileWithinLimit(file, mediaType) {
|
||||
return Boolean(file) && Number(file.size || 0) <= getRegionMediaUploadLimitBytes(mediaType);
|
||||
}
|
||||
|
||||
function rejectOversizeRegionImage(input, file) {
|
||||
return rejectOversizeRegionMedia(input, file, 'image');
|
||||
}
|
||||
|
||||
function rejectOversizeRegionMedia(input, file, mediaType) {
|
||||
var zone = getRegionUploadZone(input);
|
||||
var hiddenValue = getRegionUploadHiddenValue(input);
|
||||
var limitLabel = getRegionMediaUploadLimitLabel(mediaType || getRegionMediaTypeFromInput(input));
|
||||
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast('File must be ' + limitLabel + ' or smaller.', 'warning');
|
||||
} else {
|
||||
window.alert('File must be ' + limitLabel + ' or smaller.');
|
||||
}
|
||||
input.value = '';
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
renderRegionMediaPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
|
||||
if (file) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function setRegionImageInputFile(input, file) {
|
||||
return setRegionMediaInputFile(input, file);
|
||||
}
|
||||
|
||||
function setRegionMediaInputFile(input, file) {
|
||||
if (!input || !file) {
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaType = getRegionMediaTypeFromInput(input);
|
||||
|
||||
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||
rejectOversizeRegionMedia(input, file, mediaType);
|
||||
return;
|
||||
}
|
||||
|
||||
var dataTransfer = new DataTransfer();
|
||||
dataTransfer.items.add(file);
|
||||
input.files = dataTransfer.files;
|
||||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
function renderImageRegionPreview(card, value) {
|
||||
function uploadRegionImageFile(input, file) {
|
||||
return uploadRegionMediaFile(input, file);
|
||||
}
|
||||
|
||||
function uploadRegionMediaFile(input, file) {
|
||||
if (!input || !file) {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
var mediaType = getRegionMediaTypeFromInput(input);
|
||||
|
||||
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||
rejectOversizeRegionMedia(input, file, mediaType);
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||
var zone = getRegionUploadZone(input);
|
||||
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
|
||||
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
|
||||
var previousNeedsCleanup = String(input.dataset.uploadedNeedsCleanup || '') === '1';
|
||||
|
||||
input.dataset.uploadToken = uploadToken;
|
||||
setRegionUploadState(input, true, 0, 'Uploading...', true);
|
||||
|
||||
return new Promise(function (resolve, reject) {
|
||||
var xhr = new XMLHttpRequest();
|
||||
var formData = new FormData();
|
||||
formData.append('file', file, file.name || 'upload');
|
||||
|
||||
xhr.open('POST', '/slides/uploads', true);
|
||||
xhr.responseType = 'text';
|
||||
xhr.withCredentials = true;
|
||||
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||
|
||||
xhr.upload.onprogress = function (event) {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!event || !event.lengthComputable || !event.total) {
|
||||
setRegionUploadProgress(zone, 100, 'Uploading...', true);
|
||||
return;
|
||||
}
|
||||
|
||||
var percent = Math.round((event.loaded / event.total) * 100);
|
||||
setRegionUploadProgress(zone, percent, 'Uploading ' + percent + '%', false);
|
||||
};
|
||||
|
||||
xhr.onload = function () {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
var response = {
|
||||
ok: xhr.status >= 200 && xhr.status < 300,
|
||||
status: xhr.status,
|
||||
statusText: xhr.statusText,
|
||||
responseText: xhr.responseText || ''
|
||||
};
|
||||
|
||||
if (!response.ok) {
|
||||
var error = new Error(response.responseText || 'Unable to upload media.');
|
||||
error.status = response.status;
|
||||
error.responseText = response.responseText;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = {};
|
||||
try {
|
||||
payload = JSON.parse(response.responseText || '{}') || {};
|
||||
} catch (_error) {
|
||||
payload = {};
|
||||
}
|
||||
|
||||
var uploadedPath = String(payload.path || '').trim();
|
||||
if (!uploadedPath) {
|
||||
reject(new Error('Unable to upload media.'));
|
||||
return;
|
||||
}
|
||||
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
|
||||
if (hidden) {
|
||||
hidden.value = uploadedPath;
|
||||
}
|
||||
input.dataset.uploadedPath = uploadedPath;
|
||||
input.dataset.uploadedNeedsCleanup = '1';
|
||||
input.value = '';
|
||||
input.setCustomValidity('');
|
||||
renderRegionMediaPreview(card, uploadedPath);
|
||||
void syncVideoRegionDuration(card, uploadedPath);
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
|
||||
queueUploadCleanup([previousUploadedPath]);
|
||||
}
|
||||
setRegionUploadState(input, false);
|
||||
resolve(payload);
|
||||
};
|
||||
|
||||
xhr.onerror = function () {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Unable to upload media.'));
|
||||
};
|
||||
|
||||
xhr.onabort = function () {
|
||||
if (input.dataset.uploadToken !== uploadToken) {
|
||||
resolve(null);
|
||||
return;
|
||||
}
|
||||
|
||||
reject(new Error('Unable to upload media.'));
|
||||
};
|
||||
|
||||
xhr.onloadend = function () {
|
||||
if (input.dataset.uploadToken === uploadToken) {
|
||||
delete input.dataset.uploadToken;
|
||||
setRegionUploadState(input, false);
|
||||
}
|
||||
};
|
||||
|
||||
xhr.send(formData);
|
||||
}).catch(function (error) {
|
||||
if (input.dataset.uploadToken === uploadToken) {
|
||||
delete input.dataset.uploadToken;
|
||||
setRegionUploadState(input, false);
|
||||
}
|
||||
throw error;
|
||||
});
|
||||
}
|
||||
|
||||
function getPendingUploadCleanupPaths() {
|
||||
return getRegionMediaInputs().map(function (input) {
|
||||
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return String(input.dataset.uploadedPath || '').trim();
|
||||
}).filter(Boolean);
|
||||
}
|
||||
|
||||
function clearPendingUploadCleanupPaths() {
|
||||
getRegionMediaInputs().forEach(function (input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
});
|
||||
}
|
||||
|
||||
function queueUploadCleanup(uploadPaths) {
|
||||
var normalizedPaths = Array.from(new Set((uploadPaths || []).map(function (value) {
|
||||
return String(value || '').trim();
|
||||
}).filter(Boolean)));
|
||||
|
||||
if (!normalizedPaths.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var payload = JSON.stringify({ uploadPaths: normalizedPaths });
|
||||
var url = '/slides/uploads/cleanup';
|
||||
|
||||
if (navigator.sendBeacon) {
|
||||
try {
|
||||
navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' }));
|
||||
return;
|
||||
} catch (_error) {
|
||||
// Fall back to fetch below.
|
||||
}
|
||||
}
|
||||
|
||||
fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'application/json'
|
||||
},
|
||||
body: payload,
|
||||
credentials: 'same-origin',
|
||||
keepalive: true
|
||||
}).catch(function () {
|
||||
// Best-effort cleanup only.
|
||||
});
|
||||
}
|
||||
|
||||
function renderRegionMediaPreview(card, value) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
@@ -794,9 +1198,158 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaType = getRegionMediaType(card);
|
||||
var isVideo = mediaType === 'video';
|
||||
|
||||
previewBox.innerHTML = value
|
||||
? '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : 'image') + '">' +
|
||||
(isVideo
|
||||
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>'
|
||||
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />') +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>';
|
||||
|
||||
if (isVideo) {
|
||||
window.requestAnimationFrame(function () {
|
||||
var video = previewBox.querySelector('video.slide-image-region-preview');
|
||||
if (!video) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
video.load();
|
||||
} catch (_error) {
|
||||
// Ignore load failures; play() will retry if the browser allows it.
|
||||
}
|
||||
var playPromise = video.play && video.play();
|
||||
if (playPromise && typeof playPromise.catch === 'function') {
|
||||
playPromise.catch(function () {
|
||||
return null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function loadVideoDuration(mediaPath) {
|
||||
var cacheKey = String(mediaPath || '').trim();
|
||||
var cachedPromise;
|
||||
|
||||
if (!cacheKey) {
|
||||
return Promise.reject(new Error('No video source is available for this slide.'));
|
||||
}
|
||||
|
||||
cachedPromise = videoDurationCache[cacheKey];
|
||||
if (cachedPromise) {
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
cachedPromise = new Promise(function (resolve, reject) {
|
||||
var video = document.createElement('video');
|
||||
var timeoutId = window.setTimeout(function () {
|
||||
cleanup();
|
||||
reject(new Error('Timed out loading the video metadata.'));
|
||||
}, 15000);
|
||||
|
||||
function cleanup() {
|
||||
window.clearTimeout(timeoutId);
|
||||
video.removeAttribute('src');
|
||||
video.load();
|
||||
if (video.parentNode) {
|
||||
video.parentNode.removeChild(video);
|
||||
}
|
||||
}
|
||||
|
||||
video.preload = 'metadata';
|
||||
video.muted = true;
|
||||
video.playsInline = true;
|
||||
video.setAttribute('playsinline', '');
|
||||
video.setAttribute('muted', '');
|
||||
video.style.position = 'absolute';
|
||||
video.style.left = '-9999px';
|
||||
video.style.top = '0';
|
||||
video.style.width = '1px';
|
||||
video.style.height = '1px';
|
||||
video.style.opacity = '0';
|
||||
video.style.pointerEvents = 'none';
|
||||
|
||||
video.addEventListener('loadedmetadata', function () {
|
||||
var duration = Number(video.duration);
|
||||
cleanup();
|
||||
if (Number.isFinite(duration) && duration > 0) {
|
||||
resolve(Math.max(0.001, Math.round(duration * 1000) / 1000));
|
||||
} else {
|
||||
reject(new Error('The video duration could not be read.'));
|
||||
}
|
||||
}, { once: true });
|
||||
|
||||
video.addEventListener('error', function () {
|
||||
cleanup();
|
||||
reject(new Error('The video duration could not be read.'));
|
||||
}, { once: true });
|
||||
|
||||
video.addEventListener('abort', function () {
|
||||
cleanup();
|
||||
reject(new Error('The video duration could not be read.'));
|
||||
}, { once: true });
|
||||
|
||||
document.body.appendChild(video);
|
||||
video.src = cacheKey;
|
||||
video.load();
|
||||
}).catch(function (error) {
|
||||
delete videoDurationCache[cacheKey];
|
||||
throw error;
|
||||
});
|
||||
|
||||
videoDurationCache[cacheKey] = cachedPromise;
|
||||
return cachedPromise;
|
||||
}
|
||||
|
||||
async function syncVideoRegionDuration(card, source) {
|
||||
if (!card) {
|
||||
return;
|
||||
}
|
||||
|
||||
var regionId = card.getAttribute('data-region-id');
|
||||
var hidden = regionId ? getRegionVideoDurationHiddenInput(regionId) : null;
|
||||
|
||||
if (!hidden) {
|
||||
return;
|
||||
}
|
||||
|
||||
var mediaPath = String(source || '').trim();
|
||||
if (!mediaPath) {
|
||||
hidden.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
hidden.value = String(await loadVideoDuration(mediaPath));
|
||||
} catch (_error) {
|
||||
hidden.value = '';
|
||||
}
|
||||
}
|
||||
|
||||
function renderImageRegionPreview(card, value) {
|
||||
return renderRegionMediaPreview(card, value);
|
||||
}
|
||||
|
||||
function getRegionImageInputs() {
|
||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]'));
|
||||
}
|
||||
|
||||
function getRegionMediaInputs() {
|
||||
return getRegionImageInputs();
|
||||
}
|
||||
|
||||
function hasPendingRegionImageUpload() {
|
||||
return getRegionImageInputs().some(function (input) {
|
||||
return Boolean(input && input.files && input.files.length);
|
||||
});
|
||||
}
|
||||
|
||||
function hasPendingRegionMediaUpload() {
|
||||
return hasPendingRegionImageUpload();
|
||||
}
|
||||
|
||||
function getRegionBox(region, canvasWidth, canvasHeight) {
|
||||
@@ -822,23 +1375,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'<meta charset="utf-8" />' +
|
||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
||||
'<title>Slide preview</title>' +
|
||||
'<style>' +
|
||||
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
|
||||
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
|
||||
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
|
||||
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
|
||||
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
|
||||
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
|
||||
'.slide-preview-overlay{z-index:2;}' +
|
||||
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
|
||||
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
|
||||
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
|
||||
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
|
||||
'</style>' +
|
||||
'<link rel="stylesheet" href="/assets/css/theme-custom.css" />' +
|
||||
'</head>' +
|
||||
'<body>' +
|
||||
'<div id="popup-preview-stage">' +
|
||||
'<div id="popup-preview-canvas"></div>' +
|
||||
'<body class="slide-preview-popup-body">' +
|
||||
'<div class="slide-preview-popup-stage" id="popup-preview-stage">' +
|
||||
'<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>' +
|
||||
'</div>' +
|
||||
'</body>' +
|
||||
'</html>';
|
||||
@@ -886,6 +1427,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (popupOverlay) {
|
||||
popupOverlay.innerHTML = '';
|
||||
}
|
||||
startPreviewVideoPlayback(popupCanvas);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -907,6 +1449,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
popupEmpty.style.display = 'none';
|
||||
}
|
||||
|
||||
startPreviewVideoPlayback(popupCanvas);
|
||||
|
||||
var stageRect = popupStage.getBoundingClientRect();
|
||||
if (!stageRect.width || !stageRect.height) {
|
||||
if (previewPopupRenderFrame) {
|
||||
@@ -1058,6 +1602,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
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 videoHidden = card && card.querySelector('input[type="hidden"][name="existing_region_video_' + region.id + '"]');
|
||||
var videoFileInput = card && card.querySelector('input[type="file"][name="region_video_' + region.id + '"]');
|
||||
var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
|
||||
var rtmpInput = card && card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
|
||||
var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
|
||||
@@ -1070,6 +1616,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var existingApi = existingContent[region.region_key] || {};
|
||||
var value = region.region_type === 'image'
|
||||
? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
|
||||
: region.region_type === 'video'
|
||||
? ((videoFileInput && videoFileInput.dataset.previewUrl) ? videoFileInput.dataset.previewUrl : (videoHidden ? videoHidden.value : ''))
|
||||
: region.region_type === 'webpage'
|
||||
? (webpageInput ? webpageInput.value : '')
|
||||
: region.region_type === 'rtmp'
|
||||
@@ -1092,6 +1640,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
: getTextStyleFromCard(card, region);
|
||||
var content = region.region_type === 'image'
|
||||
? (value ? '<img class="slide-preview-image" src="' + escapeHtml(value) + '" alt="" />' : '<div class="slide-preview-placeholder">Image</div>')
|
||||
: region.region_type === 'video'
|
||||
? (value ? '<video class="slide-preview-video" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>' : '<div class="slide-preview-placeholder">Video</div>')
|
||||
: region.region_type === 'webpage'
|
||||
? renderPreviewWebpageRegion(value)
|
||||
: region.region_type === 'rtmp'
|
||||
@@ -1116,6 +1666,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
: renderPreviewTextRegion(region, value, style, scale);
|
||||
var selected = region.region_type === 'image'
|
||||
? ' slide-preview-image-region'
|
||||
: region.region_type === 'video'
|
||||
? ' slide-preview-video-region'
|
||||
: region.region_type === 'webpage'
|
||||
? ' slide-preview-webpage-region'
|
||||
: region.region_type === 'rtmp'
|
||||
@@ -1130,6 +1682,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
|
||||
}).join('');
|
||||
|
||||
startPreviewVideoPlayback(slidePreviewOverlay);
|
||||
|
||||
syncPopupPreview();
|
||||
}
|
||||
|
||||
@@ -1319,7 +1873,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
var regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="image">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Image</span>' +
|
||||
@@ -1329,10 +1883,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="image/*" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>PNG, JPG, GIF, or WebP</span>' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop an image here or click to upload</strong>' +
|
||||
'<span>PNG, JPG, GIF, or WebP</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadMaxLabel + ' per file</span>' +
|
||||
'</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
@@ -1352,6 +1915,53 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderVideoRegion(region) {
|
||||
var current = getCurrentRegionValue(region);
|
||||
var currentDuration = getCurrentRegionVideoDuration(region);
|
||||
return '' +
|
||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="video">' +
|
||||
'<div class="card-header template-field-head">' +
|
||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||
'<span class="chip">Video</span>' +
|
||||
'</div>' +
|
||||
'<div class="card-body p-3 d-grid">' +
|
||||
'<div class="row g-3 align-items-start">' +
|
||||
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
|
||||
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="video/*" />' +
|
||||
'<span class="slide-image-region-upload-zone-content">' +
|
||||
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
|
||||
'<span class="slide-image-region-upload-zone-copy">' +
|
||||
'<strong>Drop a video here or click to upload</strong>' +
|
||||
'<span>MP4, WebM, or Ogg</span>' +
|
||||
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadVideoMaxLabel + ' per file</span>' +
|
||||
'</span>' +
|
||||
'</span>' +
|
||||
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||
'</div>' +
|
||||
'</span>' +
|
||||
'</label>' +
|
||||
'</div>' +
|
||||
'<div class="col-12 col-md-4">' +
|
||||
'<div class="slide-image-region-preview-box">' +
|
||||
(current
|
||||
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
|
||||
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
|
||||
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||
'</div>'
|
||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'</div>' +
|
||||
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||
'<input type="hidden" name="' + getRegionExistingVideoDurationName(region.id) + '" value="' + escapeHtml(currentDuration) + '" />' +
|
||||
'</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function renderTemplate() {
|
||||
templateSelectorLock.reset();
|
||||
clearImagePreviewUrls();
|
||||
@@ -1367,6 +1977,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (region.region_type === 'image') {
|
||||
return renderImageRegion(region);
|
||||
}
|
||||
if (region.region_type === 'video') {
|
||||
return renderVideoRegion(region);
|
||||
}
|
||||
if (region.region_type === 'webpage') {
|
||||
return renderWebpageRegion(region);
|
||||
}
|
||||
@@ -1385,6 +1998,14 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
return renderTextRegion(region);
|
||||
}).join('') : '<div class="muted">This template has no regions.</div>';
|
||||
|
||||
templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
|
||||
var regionId = card.getAttribute('data-region-id');
|
||||
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null;
|
||||
if (hidden && hidden.value) {
|
||||
void syncVideoRegionDuration(card, hidden.value);
|
||||
}
|
||||
});
|
||||
|
||||
templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) {
|
||||
var regionId = holder.getAttribute('data-region-id');
|
||||
var source = holder.querySelector('.ckeditor-source');
|
||||
@@ -1492,26 +2113,62 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
});
|
||||
});
|
||||
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
|
||||
input.addEventListener('change', function () {
|
||||
var mediaType = getRegionMediaTypeFromInput(input);
|
||||
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||
var file = input.files && input.files.length ? input.files[0] : null;
|
||||
|
||||
if (file && !isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
input.value = '';
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.', 'warning');
|
||||
} else {
|
||||
window.alert('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.');
|
||||
}
|
||||
renderRegionMediaPreview(card, hidden ? hidden.value : '');
|
||||
return;
|
||||
}
|
||||
|
||||
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]);
|
||||
if (file) {
|
||||
input.dataset.previewUrl = URL.createObjectURL(file);
|
||||
}
|
||||
var regionId = input.name.replace(/^region_image_/, '');
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
|
||||
renderRegionMediaPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
|
||||
void syncVideoRegionDuration(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
if (file) {
|
||||
uploadRegionMediaFile(input, file).catch(function (error) {
|
||||
if (input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
input.value = '';
|
||||
if (typeof showToast === 'function') {
|
||||
showToast(error && error.message ? error.message : 'Unable to upload media.', 'danger');
|
||||
} else {
|
||||
window.alert(error && error.message ? error.message : 'Unable to upload media.');
|
||||
}
|
||||
renderRegionMediaPreview(card, hidden ? hidden.value : '');
|
||||
void syncVideoRegionDuration(card, hidden ? hidden.value : '');
|
||||
requestPreviewRender();
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
|
||||
var regionId = zone.getAttribute('data-region-upload-zone');
|
||||
var input = zone.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||
var input = zone.querySelector('input[type="file"]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
@@ -1531,66 +2188,54 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
|
||||
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
|
||||
var file = files && files.length ? files[0] : null;
|
||||
if (!file || !/^image\//i.test(file.type || '')) {
|
||||
var card = zone.closest('[data-region-id]');
|
||||
var mediaType = getRegionMediaType(card);
|
||||
if (!file || !(mediaType === 'video' ? /^video\//i.test(file.type || '') : /^image\//i.test(file.type || ''))) {
|
||||
return;
|
||||
}
|
||||
|
||||
setRegionImageInputFile(input, file);
|
||||
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||
rejectOversizeRegionMedia(input, file, mediaType);
|
||||
return;
|
||||
}
|
||||
|
||||
setRegionMediaInputFile(input, file);
|
||||
});
|
||||
});
|
||||
templateFields.querySelectorAll('button[data-remove-region-image]').forEach(function (button) {
|
||||
templateFields.querySelectorAll('[data-remove-region-image], [data-remove-region-video]').forEach(function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var regionId = button.getAttribute('data-remove-region-image');
|
||||
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video');
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||
var mediaType = getRegionMediaType(card);
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="' + getRegionMediaPrefix(mediaType) + regionId + '"]');
|
||||
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||
|
||||
if (input && input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
||||
queueUploadCleanup([uploadPath]);
|
||||
}
|
||||
if (input) {
|
||||
input.value = '';
|
||||
delete input.dataset.uploadedPath;
|
||||
delete input.dataset.uploadedNeedsCleanup;
|
||||
}
|
||||
if (hidden) {
|
||||
hidden.value = '';
|
||||
}
|
||||
var durationHidden = card ? getRegionVideoDurationHiddenInput(regionId) : null;
|
||||
if (durationHidden) {
|
||||
durationHidden.value = '';
|
||||
}
|
||||
|
||||
renderImageRegionPreview(card, '');
|
||||
renderRegionMediaPreview(card, '');
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
});
|
||||
templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
|
||||
shell.addEventListener('click', function () {
|
||||
var regionId = shell.getAttribute('data-remove-region-image');
|
||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||
|
||||
if (input && input.dataset.previewUrl) {
|
||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||
delete input.dataset.previewUrl;
|
||||
}
|
||||
if (input) {
|
||||
input.value = '';
|
||||
}
|
||||
if (hidden) {
|
||||
hidden.value = '';
|
||||
}
|
||||
|
||||
renderImageRegionPreview(card, '');
|
||||
templateSelectorLock.markEdited();
|
||||
requestPreviewRender();
|
||||
});
|
||||
|
||||
shell.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
shell.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||
input.addEventListener('input', function () {
|
||||
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
|
||||
@@ -1656,6 +2301,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
});
|
||||
submitting = true;
|
||||
try {
|
||||
if (hasPendingRegionImageUpload()) {
|
||||
throw new Error('Wait for the image upload to finish before saving.');
|
||||
}
|
||||
await Promise.all(saves);
|
||||
var formData = new FormData(slideForm);
|
||||
if (event.submitter && event.submitter.name) {
|
||||
@@ -1686,6 +2334,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
if (isCreateForm) {
|
||||
try {
|
||||
if (response.url) {
|
||||
clearPendingUploadCleanupPaths();
|
||||
window.location.replace(response.url);
|
||||
return;
|
||||
}
|
||||
@@ -1711,6 +2360,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
|
||||
showToast(savedMessage || 'Saved slide.', 'success');
|
||||
slideForm.dataset.dirty = 'false';
|
||||
clearPendingUploadCleanupPaths();
|
||||
} catch (error) {
|
||||
if (typeof showToast === 'function') {
|
||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||
@@ -1761,6 +2411,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
||||
window.addEventListener('resize', requestPreviewRender);
|
||||
window.addEventListener('resize', requestSidebarSync);
|
||||
window.addEventListener('beforeunload', function () {
|
||||
queueUploadCleanup(getPendingUploadCleanupPaths());
|
||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||
previewPopupWindow.close();
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@
|
||||
var currentRegionAspectRatio = NaN;
|
||||
var currentRegionAspectRatioLabel = 'Region';
|
||||
var listenersAttached = false;
|
||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||
var uploadMaxLabel = '100 MB';
|
||||
|
||||
function getRegionId(input) {
|
||||
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
|
||||
@@ -249,6 +251,19 @@
|
||||
input.files = dataTransfer.files;
|
||||
}
|
||||
|
||||
function rejectOversizeFile(input) {
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.showToast === 'function') {
|
||||
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
|
||||
} else {
|
||||
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
|
||||
}
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
function finalizeCropFile(file) {
|
||||
if (!currentInput) {
|
||||
return;
|
||||
@@ -357,6 +372,13 @@
|
||||
return;
|
||||
}
|
||||
|
||||
if (Number(file.size || 0) > uploadMaxBytes) {
|
||||
event.stopImmediatePropagation();
|
||||
event.stopPropagation();
|
||||
rejectOversizeFile(input);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!/^image\//i.test(file.type || '')) {
|
||||
input.value = '';
|
||||
return;
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
(function () {
|
||||
function getTableHeaderText(headerCell) {
|
||||
return String(headerCell && headerCell.textContent ? headerCell.textContent : '').trim().toLowerCase();
|
||||
}
|
||||
|
||||
function cellHasButtonContent(cell) {
|
||||
return Boolean(cell && cell.querySelector && cell.querySelector('button, form, input, select, textarea'));
|
||||
}
|
||||
|
||||
function getCellSortValue(cell) {
|
||||
if (!cell) {
|
||||
return '';
|
||||
}
|
||||
var sortValue = cell.getAttribute && cell.getAttribute('data-sort-value');
|
||||
if (sortValue !== null && sortValue !== undefined && sortValue !== '') {
|
||||
return String(sortValue).trim();
|
||||
}
|
||||
return String(cell.textContent || '').replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function getComparableSortValue(rawValue) {
|
||||
var value = String(rawValue || '').trim();
|
||||
if (!value) {
|
||||
return { type: 'empty', value: '' };
|
||||
}
|
||||
|
||||
var numericValue = Number(value.replace(/,/g, ''));
|
||||
if (!Number.isNaN(numericValue) && value !== '') {
|
||||
return { type: 'number', value: numericValue };
|
||||
}
|
||||
|
||||
var dateValue = Date.parse(value);
|
||||
if (!Number.isNaN(dateValue)) {
|
||||
return { type: 'date', value: dateValue };
|
||||
}
|
||||
|
||||
return { type: 'string', value: value.toLowerCase() };
|
||||
}
|
||||
|
||||
function compareSortValues(leftValue, rightValue) {
|
||||
if (leftValue.type === 'empty' && rightValue.type === 'empty') {
|
||||
return 0;
|
||||
}
|
||||
if (leftValue.type === 'empty') {
|
||||
return 1;
|
||||
}
|
||||
if (rightValue.type === 'empty') {
|
||||
return -1;
|
||||
}
|
||||
if (leftValue.type === rightValue.type) {
|
||||
if (leftValue.value < rightValue.value) {
|
||||
return -1;
|
||||
}
|
||||
if (leftValue.value > rightValue.value) {
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
return String(leftValue.value).localeCompare(String(rightValue.value), undefined, { numeric: true, sensitivity: 'base' });
|
||||
}
|
||||
|
||||
function getSortableTableState(table) {
|
||||
if (!table._sortableState) {
|
||||
table._sortableState = {
|
||||
columnIndex: null,
|
||||
direction: 'asc'
|
||||
};
|
||||
}
|
||||
return table._sortableState;
|
||||
}
|
||||
|
||||
function ensureSortableHeaderIndicator(headerCell) {
|
||||
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
||||
if (indicator) {
|
||||
return indicator;
|
||||
}
|
||||
|
||||
indicator = document.createElement('i');
|
||||
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
|
||||
indicator.setAttribute('aria-hidden', 'true');
|
||||
headerCell.appendChild(indicator);
|
||||
return indicator;
|
||||
}
|
||||
|
||||
function updateSortableHeaderIndicator(headerCell, isSortable, isActive, direction) {
|
||||
if (!isSortable) {
|
||||
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
||||
if (hiddenIndicator) {
|
||||
hiddenIndicator.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var indicator = ensureSortableHeaderIndicator(headerCell);
|
||||
indicator.style.display = '';
|
||||
indicator.className = 'table-sort-indicator bi ms-1';
|
||||
|
||||
if (isActive && direction === 'desc') {
|
||||
indicator.classList.add('bi-caret-down-fill');
|
||||
return;
|
||||
}
|
||||
|
||||
if (isActive && direction === 'asc') {
|
||||
indicator.classList.add('bi-caret-up-fill');
|
||||
return;
|
||||
}
|
||||
|
||||
indicator.classList.add('bi-arrow-down-up');
|
||||
}
|
||||
|
||||
function isSortableTableColumn(table, columnIndex) {
|
||||
var headerCell = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0].cells[columnIndex] : null;
|
||||
if (!headerCell) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var bodies = table.tBodies ? Array.prototype.slice.call(table.tBodies) : [];
|
||||
for (var i = 0; i < bodies.length; i += 1) {
|
||||
var rows = Array.prototype.slice.call(bodies[i].rows || []);
|
||||
for (var j = 0; j < rows.length; j += 1) {
|
||||
var cell = rows[j].cells ? rows[j].cells[columnIndex] : null;
|
||||
if (cell && cellHasButtonContent(cell)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function updateSortableHeaderState(table) {
|
||||
var state = getSortableTableState(table);
|
||||
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
|
||||
headerCells.forEach(function (headerCell, index) {
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
var sortable = isSortableTableColumn(table, index);
|
||||
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
|
||||
headerCell.removeAttribute('aria-sort');
|
||||
headerCell.removeAttribute('role');
|
||||
headerCell.removeAttribute('tabindex');
|
||||
|
||||
if (sortable) {
|
||||
headerCell.classList.add('sortable');
|
||||
updateSortableHeaderIndicator(headerCell, true, state.columnIndex === index, state.direction);
|
||||
headerCell.setAttribute('role', 'button');
|
||||
headerCell.setAttribute('tabindex', '0');
|
||||
if (state.columnIndex === index) {
|
||||
headerCell.classList.add(state.direction === 'desc' ? 'sort-desc' : 'sort-asc');
|
||||
headerCell.setAttribute('aria-sort', state.direction === 'desc' ? 'descending' : 'ascending');
|
||||
} else {
|
||||
headerCell.setAttribute('aria-sort', 'none');
|
||||
}
|
||||
} else {
|
||||
headerCell.classList.add('unsortable');
|
||||
updateSortableHeaderIndicator(headerCell, false);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function sortTable(table, columnIndex, direction) {
|
||||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
||||
if (!tbody) {
|
||||
return;
|
||||
}
|
||||
|
||||
var rows = Array.prototype.slice.call(tbody.rows || []);
|
||||
if (!rows.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
var multiplier = direction === 'desc' ? -1 : 1;
|
||||
rows.sort(function (leftRow, rightRow) {
|
||||
var leftCell = leftRow.cells ? leftRow.cells[columnIndex] : null;
|
||||
var rightCell = rightRow.cells ? rightRow.cells[columnIndex] : null;
|
||||
var leftComparable = getComparableSortValue(getCellSortValue(leftCell));
|
||||
var rightComparable = getComparableSortValue(getCellSortValue(rightCell));
|
||||
return compareSortValues(leftComparable, rightComparable) * multiplier;
|
||||
});
|
||||
|
||||
rows.forEach(function (row) {
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function applyTableSort(table) {
|
||||
var state = getSortableTableState(table);
|
||||
if (state.columnIndex === null || state.columnIndex === undefined) {
|
||||
return;
|
||||
}
|
||||
sortTable(table, state.columnIndex, state.direction);
|
||||
updateSortableHeaderState(table);
|
||||
}
|
||||
|
||||
function initSortableTables() {
|
||||
var tables = Array.prototype.slice.call(document.querySelectorAll('table'));
|
||||
tables.forEach(function (table) {
|
||||
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
|
||||
if (!headerRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
Array.prototype.forEach.call(headerRow.cells, function (headerCell, index) {
|
||||
if (!isSortableTableColumn(table, index)) {
|
||||
return;
|
||||
}
|
||||
|
||||
ensureSortableHeaderIndicator(headerCell);
|
||||
|
||||
headerCell.addEventListener('click', function () {
|
||||
var state = getSortableTableState(table);
|
||||
var nextDirection = state.columnIndex === index && state.direction === 'asc' ? 'desc' : 'asc';
|
||||
state.columnIndex = index;
|
||||
state.direction = nextDirection;
|
||||
sortTable(table, index, nextDirection);
|
||||
updateSortableHeaderState(table);
|
||||
});
|
||||
|
||||
headerCell.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
headerCell.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateSortableHeaderState(table);
|
||||
});
|
||||
}
|
||||
|
||||
window.applyTableSort = applyTableSort;
|
||||
window.initSortableTables = initSortableTables;
|
||||
}());
|
||||
@@ -0,0 +1,170 @@
|
||||
(function () {
|
||||
function rebindTableContainer(container) {
|
||||
if (!container) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (typeof window.initSortableTables === 'function') {
|
||||
window.initSortableTables(container);
|
||||
}
|
||||
|
||||
if (typeof window.initTableSearches === 'function') {
|
||||
window.initTableSearches(container);
|
||||
}
|
||||
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(container);
|
||||
}
|
||||
}
|
||||
|
||||
function focusSearchInput(input) {
|
||||
if (!input || !input.focus) {
|
||||
return;
|
||||
}
|
||||
|
||||
input.focus();
|
||||
|
||||
if (typeof input.setSelectionRange === 'function') {
|
||||
var valueLength = String(input.value || '').length;
|
||||
try {
|
||||
input.setSelectionRange(valueLength, valueLength);
|
||||
} catch (_error) {
|
||||
// Ignore selection failures on unsupported input types.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceTableResults(container, responseDocument) {
|
||||
if (!container || !responseDocument || !responseDocument.querySelector) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentTable = container.querySelector('[data-table-searchable]');
|
||||
var nextContainer = responseDocument.querySelector('[data-table-search-container]') || responseDocument.querySelector('.card');
|
||||
var nextTable = nextContainer ? nextContainer.querySelector('[data-table-searchable]') : null;
|
||||
var currentFooter = container.querySelector('.card-footer');
|
||||
var nextFooter = nextContainer ? nextContainer.querySelector('.card-footer') : null;
|
||||
|
||||
if (!currentTable || !nextTable) {
|
||||
return false;
|
||||
}
|
||||
|
||||
var currentBody = currentTable.tBodies && currentTable.tBodies[0] ? currentTable.tBodies[0] : null;
|
||||
var nextBody = nextTable.tBodies && nextTable.tBodies[0] ? nextTable.tBodies[0] : null;
|
||||
|
||||
if (!currentBody || !nextBody) {
|
||||
return false;
|
||||
}
|
||||
|
||||
currentBody.outerHTML = nextBody.outerHTML;
|
||||
|
||||
if (currentFooter && nextFooter) {
|
||||
currentFooter.outerHTML = nextFooter.outerHTML;
|
||||
} else if (currentFooter && !nextFooter) {
|
||||
currentFooter.parentNode.removeChild(currentFooter);
|
||||
} else if (!currentFooter && nextFooter) {
|
||||
currentTable.parentNode.appendChild(nextFooter.cloneNode(true));
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function initTableSearches(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
|
||||
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
|
||||
if (input.getAttribute('data-table-search-bound') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
input.setAttribute('data-table-search-bound', 'true');
|
||||
|
||||
var searchParam = String(input.getAttribute('data-table-search-param') || 'search').trim() || 'search';
|
||||
var currentUrl = new URL(window.location.href);
|
||||
var pendingSearchTimer = null;
|
||||
var requestSequence = 0;
|
||||
var container = input.closest('[data-table-search-container]') || input.closest('.card') || null;
|
||||
|
||||
input.value = String(currentUrl.searchParams.get(searchParam) || '').trim();
|
||||
|
||||
function updateSearch() {
|
||||
var query = String(input.value || '').trim();
|
||||
var nextUrl = new URL(window.location.href);
|
||||
|
||||
if (query) {
|
||||
nextUrl.searchParams.set(searchParam, query);
|
||||
} else {
|
||||
nextUrl.searchParams.delete(searchParam);
|
||||
}
|
||||
nextUrl.searchParams.delete('page');
|
||||
|
||||
if (nextUrl.toString() === currentUrl.toString()) {
|
||||
return;
|
||||
}
|
||||
|
||||
requestSequence += 1;
|
||||
var sequenceId = requestSequence;
|
||||
|
||||
fetch(nextUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/html, application/xhtml+xml',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load search results.');
|
||||
}
|
||||
return response.text();
|
||||
}).then(function (text) {
|
||||
if (sequenceId !== requestSequence) {
|
||||
return;
|
||||
}
|
||||
|
||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
|
||||
}
|
||||
|
||||
if (replaceTableResults(container, responseDocument)) {
|
||||
currentUrl = nextUrl;
|
||||
rebindTableContainer(container || document);
|
||||
focusSearchInput(input);
|
||||
}
|
||||
}).catch(function () {
|
||||
window.location.assign(nextUrl.toString());
|
||||
});
|
||||
}
|
||||
|
||||
function scheduleSearchUpdate() {
|
||||
if (pendingSearchTimer) {
|
||||
window.clearTimeout(pendingSearchTimer);
|
||||
}
|
||||
pendingSearchTimer = window.setTimeout(function () {
|
||||
pendingSearchTimer = null;
|
||||
updateSearch();
|
||||
}, 300);
|
||||
}
|
||||
|
||||
input.addEventListener('input', scheduleSearchUpdate);
|
||||
input.addEventListener('search', scheduleSearchUpdate);
|
||||
input.addEventListener('change', updateSearch);
|
||||
input.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter') {
|
||||
event.preventDefault();
|
||||
if (pendingSearchTimer) {
|
||||
window.clearTimeout(pendingSearchTimer);
|
||||
pendingSearchTimer = null;
|
||||
}
|
||||
updateSearch();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
window.initTableSearches = initTableSearches;
|
||||
window.replaceTableResults = replaceTableResults;
|
||||
}());
|
||||
@@ -0,0 +1,197 @@
|
||||
(function () {
|
||||
function getTableSortState(table) {
|
||||
if (!table._tableSortState) {
|
||||
table._tableSortState = {
|
||||
sortKey: '',
|
||||
direction: 'asc'
|
||||
};
|
||||
}
|
||||
return table._tableSortState;
|
||||
}
|
||||
|
||||
function ensureSortableHeaderIndicator(headerCell) {
|
||||
var indicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
||||
if (indicator) {
|
||||
return indicator;
|
||||
}
|
||||
|
||||
indicator = document.createElement('i');
|
||||
indicator.className = 'table-sort-indicator bi bi-arrow-down-up ms-1';
|
||||
indicator.setAttribute('aria-hidden', 'true');
|
||||
headerCell.appendChild(indicator);
|
||||
return indicator;
|
||||
}
|
||||
|
||||
function updateSortableHeaderState(table) {
|
||||
var state = getTableSortState(table);
|
||||
var currentUrl = new URL(window.location.href);
|
||||
var activeSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
|
||||
var activeDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
var headerCells = table.tHead && table.tHead.rows && table.tHead.rows.length ? Array.prototype.slice.call(table.tHead.rows[0].cells || []) : [];
|
||||
|
||||
state.sortKey = activeSortKey;
|
||||
state.direction = activeDirection;
|
||||
|
||||
headerCells.forEach(function (headerCell) {
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
|
||||
var sortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
|
||||
|
||||
headerCell.classList.remove('sort-asc', 'sort-desc', 'sortable', 'unsortable');
|
||||
headerCell.removeAttribute('aria-sort');
|
||||
|
||||
if (!sortable) {
|
||||
headerCell.classList.add('unsortable');
|
||||
var hiddenIndicator = headerCell.querySelector && headerCell.querySelector('.table-sort-indicator');
|
||||
if (hiddenIndicator) {
|
||||
hiddenIndicator.style.display = 'none';
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var indicator = ensureSortableHeaderIndicator(headerCell);
|
||||
indicator.style.display = '';
|
||||
indicator.className = 'table-sort-indicator bi ms-1';
|
||||
headerCell.classList.add('sortable');
|
||||
headerCell.setAttribute('role', 'button');
|
||||
headerCell.setAttribute('tabindex', '0');
|
||||
|
||||
if (sortKey === activeSortKey) {
|
||||
headerCell.classList.add(activeDirection === 'desc' ? 'sort-desc' : 'sort-asc');
|
||||
headerCell.setAttribute('aria-sort', activeDirection === 'desc' ? 'descending' : 'ascending');
|
||||
if (activeDirection === 'desc') {
|
||||
indicator.classList.add('bi-caret-down-fill');
|
||||
} else {
|
||||
indicator.classList.add('bi-caret-up-fill');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
headerCell.setAttribute('aria-sort', 'none');
|
||||
indicator.classList.add('bi-arrow-down-up');
|
||||
});
|
||||
}
|
||||
|
||||
function buildSortedUrl(sortKey) {
|
||||
var currentUrl = new URL(window.location.href);
|
||||
var nextUrl = new URL(window.location.href);
|
||||
var currentSortKey = String(currentUrl.searchParams.get('sort') || '').trim();
|
||||
var currentDirection = String(currentUrl.searchParams.get('direction') || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||
|
||||
if (currentSortKey === sortKey) {
|
||||
nextUrl.searchParams.set('direction', currentDirection === 'asc' ? 'desc' : 'asc');
|
||||
} else {
|
||||
nextUrl.searchParams.set('sort', sortKey);
|
||||
nextUrl.searchParams.set('direction', 'asc');
|
||||
}
|
||||
|
||||
nextUrl.searchParams.delete('page');
|
||||
return nextUrl;
|
||||
}
|
||||
|
||||
function updateUrlState(nextUrl) {
|
||||
if (window.history && window.history.replaceState) {
|
||||
window.history.replaceState({}, document.title, nextUrl.pathname + nextUrl.search + nextUrl.hash);
|
||||
}
|
||||
}
|
||||
|
||||
function refreshSortedTable(table, nextUrl, requestSequence, sequenceId) {
|
||||
var container = table ? (table.closest('[data-table-search-container]') || table.closest('.card') || null) : null;
|
||||
|
||||
fetch(nextUrl.toString(), {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Accept': 'text/html, application/xhtml+xml',
|
||||
'X-Requested-With': 'XMLHttpRequest'
|
||||
},
|
||||
credentials: 'same-origin',
|
||||
cache: 'no-store'
|
||||
}).then(function (response) {
|
||||
if (!response.ok) {
|
||||
throw new Error('Unable to load sorted results.');
|
||||
}
|
||||
return response.text();
|
||||
}).then(function (text) {
|
||||
if (sequenceId !== requestSequence.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||
updateUrlState(nextUrl);
|
||||
|
||||
if (window.replaceTableResults && replaceTableResults(container, responseDocument)) {
|
||||
if (typeof window.initSortableTables === 'function') {
|
||||
window.initSortableTables(container || document);
|
||||
}
|
||||
if (typeof window.initTableSearches === 'function') {
|
||||
window.initTableSearches(container || document);
|
||||
}
|
||||
if (typeof window.initLocalDateTimes === 'function') {
|
||||
window.initLocalDateTimes(container || document);
|
||||
}
|
||||
}
|
||||
}).catch(function () {
|
||||
window.location.assign(nextUrl.toString());
|
||||
});
|
||||
}
|
||||
|
||||
function initSortableTables(root) {
|
||||
var scope = root && root.querySelectorAll ? root : document;
|
||||
|
||||
Array.prototype.forEach.call(scope.querySelectorAll('table[data-table-searchable]'), function (table) {
|
||||
var headerRow = table.tHead && table.tHead.rows && table.tHead.rows.length ? table.tHead.rows[0] : null;
|
||||
if (!headerRow) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (table.getAttribute('data-table-sort-bound') === 'true') {
|
||||
updateSortableHeaderState(table);
|
||||
return;
|
||||
}
|
||||
|
||||
table.setAttribute('data-table-sort-bound', 'true');
|
||||
var requestSequence = { value: 0 };
|
||||
|
||||
Array.prototype.forEach.call(headerRow.cells, function (headerCell) {
|
||||
if (!headerCell) {
|
||||
return;
|
||||
}
|
||||
|
||||
var sortKey = String(headerCell.getAttribute && headerCell.getAttribute('data-table-sort-key') || '').trim();
|
||||
var isSortable = Boolean(sortKey) && String(headerCell.getAttribute('data-sortable') || '').toLowerCase() !== 'false';
|
||||
if (!isSortable) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (headerCell.getAttribute('data-table-sort-bound') === 'true') {
|
||||
return;
|
||||
}
|
||||
|
||||
headerCell.setAttribute('data-table-sort-bound', 'true');
|
||||
ensureSortableHeaderIndicator(headerCell);
|
||||
|
||||
headerCell.addEventListener('click', function () {
|
||||
var nextUrl = buildSortedUrl(sortKey);
|
||||
requestSequence.value += 1;
|
||||
var sequenceId = requestSequence.value;
|
||||
refreshSortedTable(table, nextUrl, requestSequence, sequenceId);
|
||||
});
|
||||
|
||||
headerCell.addEventListener('keydown', function (event) {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
headerCell.click();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
updateSortableHeaderState(table);
|
||||
});
|
||||
}
|
||||
|
||||
window.initSortableTables = initSortableTables;
|
||||
initSortableTables();
|
||||
}());
|
||||
@@ -1,606 +0,0 @@
|
||||
(function () {
|
||||
var dataElement = document.getElementById('template-editor-data');
|
||||
if (!dataElement) {
|
||||
return;
|
||||
}
|
||||
|
||||
var utils = window.templateDesignerUtils || {};
|
||||
|
||||
var templateData = {};
|
||||
try {
|
||||
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
|
||||
} catch (_error) {
|
||||
templateData = {};
|
||||
}
|
||||
|
||||
var existingRegions = Array.isArray(templateData.regions)
|
||||
? templateData.regions
|
||||
: (Array.isArray(templateData) ? templateData : []);
|
||||
var stage = document.getElementById('designer-stage');
|
||||
var overlay = document.getElementById('designer-overlay');
|
||||
var regionList = document.getElementById('region-list');
|
||||
var regionSelect = document.getElementById('region-select');
|
||||
var canvasSizeSelect = document.getElementById('canvas-size-select');
|
||||
var canvasSizeIdInput = document.getElementById('canvas-size-id');
|
||||
var canvasSizeSummary = document.getElementById('canvas-size-summary');
|
||||
var canvasWidthInput = document.getElementById('canvas-width');
|
||||
var canvasHeightInput = document.getElementById('canvas-height');
|
||||
var backgroundInput = document.getElementById('background-image');
|
||||
var backgroundColorInput = document.getElementById('background-color');
|
||||
var backgroundPreview = document.getElementById('background-preview');
|
||||
var backgroundEmpty = document.getElementById('background-empty');
|
||||
var removeBackgroundButton = document.getElementById('remove-background-image');
|
||||
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
|
||||
var addRegionButton = document.getElementById('add-region-button');
|
||||
var regionAddModal = document.getElementById('region-add-modal');
|
||||
var regionCardTemplate = document.getElementById('region-card-template');
|
||||
var regionsJsonInput = document.getElementById('regions-json');
|
||||
var templateForm = document.getElementById('template-form');
|
||||
var draft = null;
|
||||
var selectedIndex = -1;
|
||||
var overlayRenderFrame = 0;
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
|
||||
function valueOrDefault(value, fallback) {
|
||||
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
|
||||
}
|
||||
|
||||
function getCanvasSize() {
|
||||
return {
|
||||
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
|
||||
height: Math.max(1, Number(canvasHeightInput.value || 1080))
|
||||
};
|
||||
}
|
||||
|
||||
function clamp(value, min, max) {
|
||||
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
|
||||
}
|
||||
|
||||
function getCards() {
|
||||
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
|
||||
}
|
||||
|
||||
function cardAt(index) {
|
||||
return getCards()[index] || null;
|
||||
}
|
||||
|
||||
function getRegionName(card) {
|
||||
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
|
||||
}
|
||||
|
||||
function syncRegionIdentity(card, value) {
|
||||
if (utils.syncRegionIdentity) {
|
||||
utils.syncRegionIdentity(card, value);
|
||||
return;
|
||||
}
|
||||
var next = String(value || '').trim();
|
||||
card.querySelector('[name="region_name[]"]').value = next;
|
||||
card.querySelector('[name="region_key[]"]').value = next;
|
||||
card.querySelector('[name="region_label[]"]').value = next;
|
||||
}
|
||||
|
||||
function validateRegionNames() {
|
||||
var cards = getCards();
|
||||
var names = {};
|
||||
var hasDuplicate = false;
|
||||
|
||||
cards.forEach(function (card) {
|
||||
var input = card.querySelector('[name="region_name[]"]');
|
||||
if (!input) {
|
||||
return;
|
||||
}
|
||||
|
||||
var normalized = String(input.value || '').trim().toLowerCase();
|
||||
if (!normalized) {
|
||||
input.setCustomValidity('Region name is required.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!names[normalized]) {
|
||||
names[normalized] = [];
|
||||
}
|
||||
names[normalized].push(input);
|
||||
});
|
||||
|
||||
Object.keys(names).forEach(function (key) {
|
||||
var inputs = names[key];
|
||||
if (inputs.length > 1) {
|
||||
hasDuplicate = true;
|
||||
inputs.forEach(function (input) {
|
||||
input.setCustomValidity('Region names must be unique on this template.');
|
||||
});
|
||||
} else {
|
||||
inputs[0].setCustomValidity('');
|
||||
}
|
||||
});
|
||||
|
||||
return !hasDuplicate;
|
||||
}
|
||||
|
||||
function readCard(card) {
|
||||
return utils.readCard ? utils.readCard(card) : {
|
||||
region_key: getRegionName(card),
|
||||
label: getRegionName(card),
|
||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
||||
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
|
||||
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
|
||||
};
|
||||
}
|
||||
|
||||
function writeCard(card, values) {
|
||||
if (utils.writeCard) {
|
||||
utils.writeCard(card, values);
|
||||
return;
|
||||
}
|
||||
if (values.region_name !== undefined) {
|
||||
syncRegionIdentity(card, values.region_name);
|
||||
} else if (values.region_key !== undefined) {
|
||||
syncRegionIdentity(card, values.region_key);
|
||||
} else if (values.label !== undefined) {
|
||||
syncRegionIdentity(card, values.label);
|
||||
}
|
||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
||||
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
|
||||
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
|
||||
}
|
||||
|
||||
function getOverlayRect() {
|
||||
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
|
||||
}
|
||||
|
||||
function toCanvasPoint(event) {
|
||||
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
|
||||
x: 0,
|
||||
y: 0
|
||||
};
|
||||
}
|
||||
|
||||
function canvasRectToPixels(region) {
|
||||
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width: 0,
|
||||
height: 0
|
||||
};
|
||||
}
|
||||
|
||||
function updateAspectRatio() {
|
||||
var size = getCanvasSize();
|
||||
stage.style.aspectRatio = size.width + ' / ' + size.height;
|
||||
}
|
||||
|
||||
function updateCanvasSizeSummary() {
|
||||
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
|
||||
canvasSizeSummary.textContent = option ? option.textContent : '';
|
||||
}
|
||||
|
||||
function updateCanvasSizeLock() {
|
||||
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
|
||||
var locked = lockOnExistingTemplate && getCards().length > 0;
|
||||
canvasSizeSelect.disabled = locked;
|
||||
canvasSizeSummary.classList.toggle('is-locked', locked);
|
||||
}
|
||||
|
||||
function syncCanvasSizeSelection() {
|
||||
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
|
||||
if (!option) {
|
||||
return;
|
||||
}
|
||||
if (canvasSizeIdInput) {
|
||||
canvasSizeIdInput.value = option.value;
|
||||
}
|
||||
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
|
||||
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
|
||||
updateAspectRatio();
|
||||
updateCanvasSizeSummary();
|
||||
}
|
||||
|
||||
function updateBackgroundPreview(file) {
|
||||
if (!file) {
|
||||
return;
|
||||
}
|
||||
if (removeBackgroundFlag) {
|
||||
removeBackgroundFlag.checked = false;
|
||||
}
|
||||
var reader = new FileReader();
|
||||
reader.onload = function () {
|
||||
backgroundPreview.src = reader.result;
|
||||
backgroundPreview.style.display = 'block';
|
||||
backgroundEmpty.style.display = 'none';
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
|
||||
function updateStageBackgroundColor() {
|
||||
if (!stage) {
|
||||
return;
|
||||
}
|
||||
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
var chip = card.querySelector('[data-region-chip]');
|
||||
var title = card.querySelector('[data-region-title]');
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
|
||||
var regionTypeInput = card.querySelector('[name="region_type[]"]');
|
||||
var regionKeyInput = card.querySelector('[name="region_key[]"]');
|
||||
var regionLabelInput = card.querySelector('[name="region_label[]"]');
|
||||
|
||||
if (title) {
|
||||
title.textContent = region.label || region.region_key || 'Region';
|
||||
}
|
||||
if (chip) {
|
||||
chip.textContent = getRegionChipLabel(region.region_type);
|
||||
}
|
||||
if (nameInput) {
|
||||
nameInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (fontFamilyInput) {
|
||||
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
|
||||
}
|
||||
if (regionTypeInput) {
|
||||
regionTypeInput.value = region.region_type || 'text';
|
||||
}
|
||||
if (regionKeyInput) {
|
||||
regionKeyInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (regionLabelInput) {
|
||||
regionLabelInput.value = region.label || region.region_key || '';
|
||||
}
|
||||
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
|
||||
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
||||
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
|
||||
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
|
||||
}
|
||||
|
||||
function updateRegionLabel(card) {
|
||||
var label = getRegionName(card) || 'Region';
|
||||
var cards = getCards();
|
||||
var index = cards.indexOf(card);
|
||||
var title = card.querySelector('.template-field-head strong');
|
||||
if (title) {
|
||||
title.textContent = label;
|
||||
}
|
||||
if (index >= 0 && regionSelect.options[index]) {
|
||||
regionSelect.options[index].textContent = label;
|
||||
}
|
||||
}
|
||||
|
||||
function makeRegionCard(region) {
|
||||
var card;
|
||||
if (regionCardTemplate && regionCardTemplate.content) {
|
||||
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
|
||||
} else {
|
||||
card = document.createElement('div');
|
||||
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
||||
}
|
||||
populateRegionCard(card, region);
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
nameInput.addEventListener('input', function () {
|
||||
syncRegionIdentity(card, nameInput.value);
|
||||
updateRegionLabel(card);
|
||||
validateRegionNames();
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
card.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
||||
return;
|
||||
}
|
||||
setSelected(getCards().indexOf(card));
|
||||
});
|
||||
card.querySelector('.remove-region').addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
card.remove();
|
||||
if (!getCards().length) {
|
||||
selectedIndex = -1;
|
||||
} else if (selectedIndex >= getCards().length) {
|
||||
selectedIndex = getCards().length - 1;
|
||||
}
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
});
|
||||
return card;
|
||||
}
|
||||
|
||||
function renderRegionList(initialRegions) {
|
||||
regionList.innerHTML = '';
|
||||
initialRegions.forEach(function (region) {
|
||||
regionList.appendChild(makeRegionCard(region));
|
||||
});
|
||||
}
|
||||
|
||||
function renderRegionSidebar() {
|
||||
var cards = getCards();
|
||||
regionSelect.innerHTML = '';
|
||||
if (!cards.length) {
|
||||
regionSelect.disabled = true;
|
||||
regionList.innerHTML = '';
|
||||
updateCanvasSizeLock();
|
||||
return;
|
||||
}
|
||||
regionSelect.disabled = false;
|
||||
if (selectedIndex < 0 || selectedIndex >= cards.length) {
|
||||
selectedIndex = 0;
|
||||
}
|
||||
cards.forEach(function (card, index) {
|
||||
var option = document.createElement('option');
|
||||
option.value = String(index);
|
||||
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
|
||||
if (index === selectedIndex) {
|
||||
option.selected = true;
|
||||
}
|
||||
regionSelect.appendChild(option);
|
||||
card.hidden = index !== selectedIndex;
|
||||
});
|
||||
regionSelect.value = String(selectedIndex);
|
||||
updateCanvasSizeLock();
|
||||
validateRegionNames();
|
||||
}
|
||||
|
||||
function renderOverlay() {
|
||||
var cards = getCards();
|
||||
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
|
||||
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
|
||||
var regions = cards.map(readCard);
|
||||
regionsJsonInput.value = JSON.stringify(regions);
|
||||
overlay.innerHTML = regions.map(function (region, index) {
|
||||
var box = canvasRectToPixels(region);
|
||||
var selected = index === selectedNow ? ' selected' : '';
|
||||
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
|
||||
}).join('');
|
||||
if (draft) {
|
||||
var rect = getOverlayRect();
|
||||
var size = getCanvasSize();
|
||||
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
|
||||
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
|
||||
}
|
||||
}
|
||||
|
||||
function requestOverlayRender() {
|
||||
if (overlayRenderFrame) {
|
||||
return;
|
||||
}
|
||||
|
||||
overlayRenderFrame = window.requestAnimationFrame(function () {
|
||||
overlayRenderFrame = 0;
|
||||
renderOverlay();
|
||||
});
|
||||
}
|
||||
|
||||
function render() {
|
||||
updateAspectRatio();
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function setSelected(index) {
|
||||
var cards = getCards();
|
||||
if (!cards.length) {
|
||||
selectedIndex = -1;
|
||||
} else if (index < 0) {
|
||||
selectedIndex = 0;
|
||||
} else {
|
||||
selectedIndex = clamp(index, 0, cards.length - 1);
|
||||
}
|
||||
renderRegionSidebar();
|
||||
renderOverlay();
|
||||
}
|
||||
|
||||
function addRegion(region) {
|
||||
var hint = regionList.querySelector('.muted');
|
||||
if (hint) {
|
||||
hint.remove();
|
||||
}
|
||||
regionList.appendChild(makeRegionCard(region));
|
||||
setSelected(getCards().length - 1);
|
||||
}
|
||||
|
||||
function openAddRegionModal() {
|
||||
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
|
||||
return;
|
||||
}
|
||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
|
||||
}
|
||||
|
||||
function createDefaultRegion(type) {
|
||||
var count = getCards().length + 1;
|
||||
var name = 'region_' + count;
|
||||
return {
|
||||
region_key: name,
|
||||
label: name,
|
||||
region_type: type,
|
||||
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
|
||||
x: 80,
|
||||
y: 80,
|
||||
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
|
||||
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
|
||||
z_index: 1
|
||||
};
|
||||
}
|
||||
|
||||
function clampRegion(region) {
|
||||
var size = getCanvasSize();
|
||||
var minSize = 12;
|
||||
var x = clamp(region.x, 0, size.width - minSize);
|
||||
var y = clamp(region.y, 0, size.height - minSize);
|
||||
var width = Math.max(minSize, region.width);
|
||||
var height = Math.max(minSize, region.height);
|
||||
if (x + width > size.width) {
|
||||
width = size.width - x;
|
||||
}
|
||||
if (y + height > size.height) {
|
||||
height = size.height - y;
|
||||
}
|
||||
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
|
||||
}
|
||||
|
||||
function startDraw(event) {
|
||||
var start = toCanvasPoint(event);
|
||||
draft = { start: start, end: start };
|
||||
renderOverlay();
|
||||
function moveHandler(moveEvent) {
|
||||
draft.end = toCanvasPoint(moveEvent);
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler(upEvent) {
|
||||
draft.end = toCanvasPoint(upEvent);
|
||||
var width = Math.abs(draft.end.x - draft.start.x);
|
||||
var height = Math.abs(draft.end.y - draft.start.y);
|
||||
if (width >= 8 && height >= 8) {
|
||||
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
|
||||
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
|
||||
}
|
||||
draft = null;
|
||||
requestOverlayRender();
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
}
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
document.addEventListener('mouseup', upHandler);
|
||||
}
|
||||
|
||||
function startMove(index, event) {
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
function moveHandler(moveEvent) {
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
|
||||
writeCard(cardAt(index), { x: next.x, y: next.y });
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
}
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
document.addEventListener('mouseup', upHandler);
|
||||
}
|
||||
|
||||
function resizeFromHandle(index, dir, event) {
|
||||
var startPoint = toCanvasPoint(event);
|
||||
var startRegion = readCard(cardAt(index));
|
||||
function moveHandler(moveEvent) {
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
||||
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
|
||||
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
|
||||
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
|
||||
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
|
||||
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
|
||||
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
|
||||
next = clampRegion(next);
|
||||
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
document.removeEventListener('mousemove', moveHandler);
|
||||
document.removeEventListener('mouseup', upHandler);
|
||||
}
|
||||
document.addEventListener('mousemove', moveHandler);
|
||||
document.addEventListener('mouseup', upHandler);
|
||||
}
|
||||
|
||||
if (addRegionButton && regionAddModal) {
|
||||
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
|
||||
addRegionButton.addEventListener('click', function () {
|
||||
openAddRegionModal();
|
||||
});
|
||||
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
|
||||
button.addEventListener('click', function () {
|
||||
var regionType = button.getAttribute('data-add-region-type');
|
||||
addRegion(createDefaultRegion(regionType));
|
||||
if (window.bootstrap && window.bootstrap.Modal) {
|
||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
backgroundInput.addEventListener('change', function () {
|
||||
var file = backgroundInput.files && backgroundInput.files[0];
|
||||
if (file) {
|
||||
updateBackgroundPreview(file);
|
||||
}
|
||||
});
|
||||
if (removeBackgroundButton && removeBackgroundFlag) {
|
||||
removeBackgroundButton.addEventListener('click', function () {
|
||||
removeBackgroundFlag.checked = true;
|
||||
backgroundInput.value = '';
|
||||
backgroundPreview.removeAttribute('src');
|
||||
backgroundPreview.style.display = 'none';
|
||||
backgroundEmpty.style.display = 'block';
|
||||
});
|
||||
}
|
||||
if (backgroundColorInput) {
|
||||
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
|
||||
}
|
||||
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
|
||||
canvasWidthInput.addEventListener('input', render);
|
||||
canvasHeightInput.addEventListener('input', render);
|
||||
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
|
||||
overlay.addEventListener('mousedown', function (event) {
|
||||
var rect = event.target.closest('.designer-rect');
|
||||
if (rect) {
|
||||
var index = Number(rect.getAttribute('data-index'));
|
||||
var handle = event.target.closest('.resize-handle');
|
||||
event.preventDefault();
|
||||
setSelected(index);
|
||||
if (handle) {
|
||||
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
|
||||
} else {
|
||||
startMove(index, event);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
setSelected(-1);
|
||||
startDraw(event);
|
||||
});
|
||||
if (templateForm) {
|
||||
templateForm.addEventListener('formdata', function (event) {
|
||||
if (!validateRegionNames()) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
syncCanvasSizeSelection();
|
||||
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
|
||||
});
|
||||
|
||||
templateForm.addEventListener('submit', function () {
|
||||
if (!validateRegionNames()) {
|
||||
return;
|
||||
}
|
||||
syncCanvasSizeSelection();
|
||||
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
|
||||
});
|
||||
}
|
||||
|
||||
renderRegionList(existingRegions);
|
||||
syncCanvasSizeSelection();
|
||||
updateStageBackgroundColor();
|
||||
render();
|
||||
})();
|
||||
@@ -44,7 +44,7 @@
|
||||
|
||||
function getDefaultRegionSize(regionType, lockRatio) {
|
||||
var ratio = parseLockRatio(lockRatio);
|
||||
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
|
||||
var locked = regionType === 'image' || regionType === 'video' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
|
||||
|
||||
if (ratio) {
|
||||
if (ratio.ratio >= 1) {
|
||||
|
||||
@@ -36,10 +36,26 @@
|
||||
var regionCardTemplate = document.getElementById('region-card-template');
|
||||
var regionsJsonInput = document.getElementById('regions-json');
|
||||
var templateForm = document.getElementById('template-form');
|
||||
var templateRegionUsageElement = document.getElementById('template-region-usage');
|
||||
var regionUsageSet = new Set();
|
||||
var draft = null;
|
||||
var selectedIndex = -1;
|
||||
var overlayRenderFrame = 0;
|
||||
|
||||
try {
|
||||
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
|
||||
if (Array.isArray(regionUsageData)) {
|
||||
regionUsageData.forEach(function (regionKey) {
|
||||
var normalized = String(regionKey || '').trim();
|
||||
if (normalized) {
|
||||
regionUsageSet.add(normalized);
|
||||
}
|
||||
});
|
||||
}
|
||||
} catch (_error) {
|
||||
regionUsageSet = new Set();
|
||||
}
|
||||
|
||||
function escapeHtml(value) {
|
||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||
}
|
||||
@@ -312,7 +328,7 @@
|
||||
}
|
||||
|
||||
function getRegionChipLabel(regionType) {
|
||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||
}
|
||||
|
||||
function populateRegionCard(card, region) {
|
||||
@@ -334,7 +350,7 @@
|
||||
nameInput.value = region.region_key || region.label || '';
|
||||
}
|
||||
if (fontFamilyInput) {
|
||||
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
||||
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
||||
}
|
||||
if (regionTypeInput) {
|
||||
regionTypeInput.value = region.region_type || 'text';
|
||||
@@ -357,6 +373,21 @@
|
||||
updateRegionLockBadge(card);
|
||||
}
|
||||
|
||||
function setRegionRemovalState(card) {
|
||||
var removeButton = card.querySelector('[data-region-remove-button]');
|
||||
if (!removeButton) {
|
||||
return;
|
||||
}
|
||||
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
|
||||
var regionDeletionLocked = regionUsageSet.has(regionKey);
|
||||
removeButton.disabled = regionDeletionLocked;
|
||||
if (regionDeletionLocked) {
|
||||
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
|
||||
} else {
|
||||
removeButton.removeAttribute('title');
|
||||
}
|
||||
}
|
||||
|
||||
function updateRegionLabel(card) {
|
||||
var label = getRegionName(card) || 'Region';
|
||||
var cards = getCards();
|
||||
@@ -379,6 +410,8 @@
|
||||
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
||||
}
|
||||
populateRegionCard(card, region);
|
||||
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
|
||||
setRegionRemovalState(card);
|
||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||
@@ -415,12 +448,12 @@
|
||||
});
|
||||
}
|
||||
card.addEventListener('click', function (event) {
|
||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
||||
if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
|
||||
return;
|
||||
}
|
||||
setSelected(getCards().indexOf(card));
|
||||
});
|
||||
card.querySelector('.remove-region').addEventListener('click', function (event) {
|
||||
card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
|
||||
event.preventDefault();
|
||||
card.remove();
|
||||
if (!getCards().length) {
|
||||
@@ -523,7 +556,9 @@
|
||||
if (hint) {
|
||||
hint.remove();
|
||||
}
|
||||
regionList.appendChild(makeRegionCard(region));
|
||||
var card = makeRegionCard(region);
|
||||
setRegionRemovalState(card);
|
||||
regionList.appendChild(card);
|
||||
setSelected(getCards().length - 1);
|
||||
}
|
||||
|
||||
@@ -600,8 +635,12 @@
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
|
||||
writeCard(cardAt(index), { x: next.x, y: next.y });
|
||||
var size = getCanvasSize();
|
||||
var maxX = Math.max(0, size.width - startRegion.width);
|
||||
var maxY = Math.max(0, size.height - startRegion.height);
|
||||
var nextX = clamp(startRegion.x + dx, 0, maxX);
|
||||
var nextY = clamp(startRegion.y + dy, 0, maxY);
|
||||
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
|
||||
requestOverlayRender();
|
||||
}
|
||||
function upHandler() {
|
||||
@@ -617,6 +656,24 @@
|
||||
var startRegion = readCard(cardAt(index));
|
||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||
var canvasSize = getCanvasSize();
|
||||
var minSize = 12;
|
||||
|
||||
function clampResizeDelta(dx, dy) {
|
||||
if (dir.indexOf('w') !== -1) {
|
||||
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
|
||||
}
|
||||
if (dir.indexOf('e') !== -1) {
|
||||
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
|
||||
}
|
||||
if (dir.indexOf('n') !== -1) {
|
||||
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
|
||||
}
|
||||
if (dir.indexOf('s') !== -1) {
|
||||
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
|
||||
}
|
||||
return { dx: dx, dy: dy };
|
||||
}
|
||||
|
||||
function fitFromWidth(width) {
|
||||
var nextWidth = Math.max(12, Math.round(width));
|
||||
@@ -638,6 +695,9 @@
|
||||
var currentPoint = toCanvasPoint(moveEvent);
|
||||
var dx = currentPoint.x - startPoint.x;
|
||||
var dy = currentPoint.y - startPoint.y;
|
||||
var constrainedDelta = clampResizeDelta(dx, dy);
|
||||
dx = constrainedDelta.dx;
|
||||
dy = constrainedDelta.dy;
|
||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
||||
if (aspect) {
|
||||
if (dir === 'e') {
|
||||
@@ -748,7 +808,6 @@
|
||||
}
|
||||
event.preventDefault();
|
||||
setSelected(-1);
|
||||
startDraw(event);
|
||||
});
|
||||
if (templateForm) {
|
||||
templateForm.addEventListener('formdata', function (event) {
|
||||
|
||||
@@ -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();
|
||||
}());
|
||||
+113
-9
@@ -1,4 +1,110 @@
|
||||
(function () {
|
||||
function getToastTitle(variant) {
|
||||
var textVariant = String(variant || '').trim().toLowerCase();
|
||||
if (textVariant === 'danger') {
|
||||
return 'Error';
|
||||
}
|
||||
if (textVariant === 'warning') {
|
||||
return 'Warning';
|
||||
}
|
||||
if (textVariant === 'success') {
|
||||
return 'Success';
|
||||
}
|
||||
return 'Pulse';
|
||||
}
|
||||
|
||||
function getToastTimeLabel(createdAt) {
|
||||
var timestamp = Number(createdAt);
|
||||
if (!timestamp || Number.isNaN(timestamp)) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
var elapsed = Date.now() - timestamp;
|
||||
if (elapsed < 60 * 1000) {
|
||||
return 'just now';
|
||||
}
|
||||
|
||||
var minutes = Math.floor(elapsed / (60 * 1000));
|
||||
if (minutes < 60) {
|
||||
return minutes + ' min' + (minutes === 1 ? '' : 's') + ' ago';
|
||||
}
|
||||
|
||||
var hours = Math.floor(minutes / 60);
|
||||
if (hours < 24) {
|
||||
return hours + ' hour' + (hours === 1 ? '' : 's') + ' ago';
|
||||
}
|
||||
|
||||
var days = Math.floor(hours / 24);
|
||||
if (days < 7) {
|
||||
return days + ' day' + (days === 1 ? '' : 's') + ' ago';
|
||||
}
|
||||
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
|
||||
function getToastButtonClass(variant) {
|
||||
var textVariant = String(variant || '').trim().toLowerCase();
|
||||
return textVariant === 'light' || textVariant === 'warning' ? 'btn-close' : 'btn-close-white';
|
||||
}
|
||||
|
||||
function getToastIconClass(variant) {
|
||||
var textVariant = String(variant || '').trim().toLowerCase();
|
||||
if (textVariant === 'success') {
|
||||
return 'bi-check-circle-fill';
|
||||
}
|
||||
if (textVariant === 'warning') {
|
||||
return 'bi-exclamation-triangle-fill';
|
||||
}
|
||||
if (textVariant === 'danger') {
|
||||
return 'bi-x-circle-fill';
|
||||
}
|
||||
if (textVariant === 'primary') {
|
||||
return 'bi-bell-fill';
|
||||
}
|
||||
return 'bi-info-circle-fill';
|
||||
}
|
||||
|
||||
function buildToastMarkup(title, timeLabel, buttonClass, iconClass) {
|
||||
return '<div class="toast-header border-0">' +
|
||||
'<i class="bi ' + iconClass + ' me-2"></i>' +
|
||||
'<strong class="me-auto">' + title + '</strong>' +
|
||||
'<small class="toast-time text-nowrap">' + timeLabel + '</small>' +
|
||||
'<button type="button" class="btn-close ' + buttonClass + ' ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>' +
|
||||
'</div>' +
|
||||
'<div class="toast-body"></div>';
|
||||
}
|
||||
|
||||
function setToastHeader(toast, variant, createdAt) {
|
||||
if (!toast) {
|
||||
return;
|
||||
}
|
||||
|
||||
var header = toast.querySelector('.toast-header');
|
||||
var icon = toast.querySelector('.toast-header i');
|
||||
var title = toast.querySelector('.toast-header .me-auto');
|
||||
var time = toast.querySelector('.toast-time');
|
||||
var closeButton = toast.querySelector('.toast-header .btn-close');
|
||||
var nextVariant = getMessageVariant('', variant);
|
||||
|
||||
if (!header) {
|
||||
return;
|
||||
}
|
||||
|
||||
header.className = 'toast-header border-0 text-bg-' + nextVariant;
|
||||
if (icon) {
|
||||
icon.className = 'bi ' + getToastIconClass(nextVariant) + ' me-2';
|
||||
}
|
||||
if (title) {
|
||||
title.textContent = getToastTitle(nextVariant);
|
||||
}
|
||||
if (time) {
|
||||
time.textContent = getToastTimeLabel(createdAt);
|
||||
}
|
||||
if (closeButton) {
|
||||
closeButton.className = 'btn-close ' + getToastButtonClass(nextVariant) + ' ms-2';
|
||||
}
|
||||
}
|
||||
|
||||
function getBootstrapToast(toast) {
|
||||
if (!toast || !window.bootstrap || !window.bootstrap.Toast) {
|
||||
return null;
|
||||
@@ -30,12 +136,7 @@
|
||||
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));
|
||||
setToastHeader(toast, variant, toast.getAttribute('data-toast-created-at') || Date.now());
|
||||
}
|
||||
|
||||
function getMessageVariant(message, fallbackVariant) {
|
||||
@@ -68,7 +169,9 @@
|
||||
}
|
||||
var nextVariant = getMessageVariant(text, variant);
|
||||
existingToast.setAttribute('data-toast-variant', nextVariant);
|
||||
existingToast.setAttribute('data-toast-created-at', String(Date.now()));
|
||||
setToastVariant(existingToast, nextVariant);
|
||||
setToastHeader(existingToast, nextVariant, Date.now());
|
||||
var existingInstance = getBootstrapToast(existingToast);
|
||||
if (existingInstance) {
|
||||
existingInstance.show();
|
||||
@@ -77,18 +180,19 @@
|
||||
}
|
||||
|
||||
var toast = document.createElement('div');
|
||||
toast.className = 'toast align-items-center border-0';
|
||||
toast.className = 'toast shadow-lg border-0';
|
||||
toast.id = 'app-toast';
|
||||
toast.setAttribute('role', 'status');
|
||||
toast.setAttribute('aria-live', 'polite');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
toast.setAttribute('data-bs-autohide', 'true');
|
||||
toast.setAttribute('data-bs-delay', '4000');
|
||||
toast.innerHTML = '<div class="d-flex"><div class="toast-body"></div><button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button></div>';
|
||||
toast.setAttribute('data-toast-created-at', String(Date.now()));
|
||||
var toastVariant = getMessageVariant(text, variant);
|
||||
toast.setAttribute('data-toast-variant', toastVariant);
|
||||
setToastVariant(toast, toastVariant);
|
||||
toast.innerHTML = buildToastMarkup(getToastTitle(toastVariant), getToastTimeLabel(Date.now()), getToastButtonClass(toastVariant), getToastIconClass(toastVariant));
|
||||
toast.querySelector('.toast-body').textContent = text;
|
||||
setToastVariant(toast, toastVariant);
|
||||
|
||||
toast.addEventListener('hidden.bs.toast', function () {
|
||||
removeToast(toast);
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
(function () {
|
||||
function escapeHtml(value) {
|
||||
return String(value ?? '')
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, ''');
|
||||
}
|
||||
|
||||
function formatDashboardDate(value) {
|
||||
if (!value) {
|
||||
return '';
|
||||
}
|
||||
|
||||
var date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return '';
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
second: '2-digit'
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function getClientRowKey(client) {
|
||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||
}
|
||||
|
||||
function getClientDisplayName(client) {
|
||||
if (client && client.client_name) {
|
||||
return String(client.client_name).trim();
|
||||
}
|
||||
|
||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||
if (clientId) {
|
||||
return clientId;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||
if (!button) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (button.classList) {
|
||||
classesToRemove.forEach(function (className) {
|
||||
button.classList.remove(className);
|
||||
});
|
||||
if (classToAdd) {
|
||||
button.classList.add(classToAdd);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
var className = String(button.className || '');
|
||||
classesToRemove.forEach(function (removeClass) {
|
||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||
});
|
||||
if (classToAdd) {
|
||||
className += ' ' + classToAdd;
|
||||
}
|
||||
button.className = className.replace(/\s+/g, ' ').trim();
|
||||
}
|
||||
|
||||
function normalizeDisplayIp(value) {
|
||||
var ip = String(value || '').trim();
|
||||
if (!ip) {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||
return ip.slice(7).trim();
|
||||
}
|
||||
|
||||
return ip;
|
||||
}
|
||||
|
||||
window.webUiHelpers = {
|
||||
escapeHtml: escapeHtml,
|
||||
formatDashboardDate: formatDashboardDate,
|
||||
getClientRowKey: getClientRowKey,
|
||||
getClientDisplayName: getClientDisplayName,
|
||||
setButtonVariant: setButtonVariant,
|
||||
normalizeDisplayIp: normalizeDisplayIp
|
||||
};
|
||||
}());
|
||||
@@ -1,3 +1,6 @@
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
|
||||
module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const pool = deps.pool;
|
||||
const common = deps.common;
|
||||
@@ -8,6 +11,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
||||
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
|
||||
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
|
||||
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
|
||||
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
||||
const getAuditUserId = deps.getAuditUserId;
|
||||
const redirectAfterSave = deps.redirectAfterSave;
|
||||
@@ -18,14 +22,142 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
||||
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
||||
const requirePermission = deps.requirePermission;
|
||||
const hasAnyPermission = deps.hasAnyPermission;
|
||||
const { buildPagination } = require('../../lib/pagination');
|
||||
|
||||
const LIST_PAGE_SIZE = 10;
|
||||
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') {
|
||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
|
||||
}
|
||||
|
||||
function requireSlideUploadPermission(req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['slides.create', 'slides.update'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
}
|
||||
|
||||
function getUploadedFileMediaType(file) {
|
||||
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
||||
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
||||
return 'video';
|
||||
}
|
||||
if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) !== -1) {
|
||||
return 'image';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function getUploadedFileLimitBytes(file) {
|
||||
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||
}
|
||||
|
||||
function getUploadedFileLimitLabel(file) {
|
||||
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
|
||||
}
|
||||
|
||||
async function removeUploadedFile(file) {
|
||||
if (!file || !file.filename || !deps.uploadDir) {
|
||||
return;
|
||||
}
|
||||
|
||||
const filePath = path.join(deps.uploadDir, file.filename);
|
||||
try {
|
||||
await fs.promises.unlink(filePath);
|
||||
} catch (_error) {
|
||||
// Ignore cleanup failures.
|
||||
}
|
||||
}
|
||||
|
||||
async function validateUploadedFiles(files) {
|
||||
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||
for (let i = 0; i < list.length; i += 1) {
|
||||
const file = list[i];
|
||||
const mediaType = getUploadedFileMediaType(file);
|
||||
if (!mediaType) {
|
||||
await removeUploadedFile(file);
|
||||
const error = new Error('Unsupported upload type.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
throw error;
|
||||
}
|
||||
|
||||
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
|
||||
await removeUploadedFile(file);
|
||||
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.');
|
||||
error.statusCode = 400;
|
||||
error.expose = true;
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchTemplateSlideCount(templateId) {
|
||||
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM slides WHERE template_id = ?', [templateId]);
|
||||
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||
}
|
||||
|
||||
async function fetchTemplateRegionUsage(template) {
|
||||
const [slides] = await pool.query('SELECT content_json FROM slides WHERE template_id = ?', [template.id]);
|
||||
const usage = new Set();
|
||||
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
|
||||
.map((region) => String(region && region.region_key || '').trim())
|
||||
.filter(Boolean));
|
||||
|
||||
if (!regionKeys.size || !slides.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
slides.forEach((slide) => {
|
||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
|
||||
if (!content || typeof content !== 'object') {
|
||||
return;
|
||||
}
|
||||
regionKeys.forEach((regionKey) => {
|
||||
if (Object.prototype.hasOwnProperty.call(content, regionKey)) {
|
||||
usage.add(regionKey);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
return Array.from(usage);
|
||||
}
|
||||
|
||||
async function fetchSlidesByTemplateId(templateId) {
|
||||
const [slides] = await pool.query('SELECT id, thumbnail_path FROM slides WHERE template_id = ? ORDER BY id ASC', [templateId]);
|
||||
return slides;
|
||||
}
|
||||
|
||||
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
|
||||
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
|
||||
return (Array.isArray(existingRegions) ? existingRegions : [])
|
||||
.map((region) => String(region && region.region_key || '').trim())
|
||||
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
|
||||
}
|
||||
|
||||
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
||||
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
||||
if (!removedRegionKeys.length) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const usedRegionKeys = Array.isArray(template && template.region_usage) ? template.region_usage : [];
|
||||
const blockedKeys = removedRegionKeys.filter((regionKey) => usedRegionKeys.includes(regionKey));
|
||||
return blockedKeys.length > 0 ? 'This region is still used by one or more slides.' : '';
|
||||
}
|
||||
|
||||
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
@@ -44,6 +176,23 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
});
|
||||
}
|
||||
|
||||
function queueTemplateSlideThumbnailRefresh(templateId) {
|
||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||
return Promise.resolve(null);
|
||||
}
|
||||
|
||||
return backgroundTaskQueue.enqueueTask({
|
||||
key: 'template-slide-thumbnail:' + Number(templateId),
|
||||
title: 'Refresh slide thumbnails',
|
||||
category: 'slides',
|
||||
taskType: 'template-slide-thumbnail-refresh',
|
||||
payload: {
|
||||
templateId: Number(templateId)
|
||||
},
|
||||
persist: true
|
||||
});
|
||||
}
|
||||
|
||||
async function canvasSizeExists(width, height, ignoreId) {
|
||||
const params = [width, height];
|
||||
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
|
||||
@@ -81,10 +230,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);
|
||||
@@ -145,8 +297,42 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/uploads', requireSlideUploadPermission, upload.single('file'), async function (req, res, next) {
|
||||
try {
|
||||
if (!req.file) {
|
||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
||||
}
|
||||
|
||||
await validateUploadedFiles([req.file]);
|
||||
|
||||
res.json({
|
||||
path: '/media/uploads/' + req.file.filename,
|
||||
filename: req.file.filename,
|
||||
originalName: req.file.originalname || ''
|
||||
});
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides/uploads/cleanup', requireSlideUploadPermission, async function (req, res, next) {
|
||||
try {
|
||||
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
|
||||
? req.body.uploadPaths
|
||||
: Array.isArray(req.body && req.body.paths)
|
||||
? req.body.paths
|
||||
: [];
|
||||
|
||||
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
|
||||
res.json({ ok: true });
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
});
|
||||
|
||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
await validateUploadedFiles(req.files || []);
|
||||
const payload = await common.buildSlidePayload(pool, req, null);
|
||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
||||
return res.status(400).send('A slide with that title already exists.');
|
||||
@@ -177,6 +363,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
|
||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||
try {
|
||||
await validateUploadedFiles(req.files || []);
|
||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||
if (!slide) {
|
||||
return res.status(404).send('Slide not found');
|
||||
@@ -251,10 +438,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);
|
||||
@@ -310,6 +500,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
if (!template) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
||||
} catch (error) {
|
||||
@@ -323,11 +514,16 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
if (!template) {
|
||||
return res.status(404).send('Template not found');
|
||||
}
|
||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
||||
const payload = await common.buildTemplatePayload(pool, req, template);
|
||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||
}
|
||||
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
|
||||
if (regionDeleteBlockMessage) {
|
||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent(regionDeleteBlockMessage));
|
||||
}
|
||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||
const actorId = getAuditUserId(req);
|
||||
@@ -350,6 +546,12 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
||||
previousUploadRefs: existingUploadRefs,
|
||||
nextUploadRefs: nextUploadRefs
|
||||
});
|
||||
const templateSlides = await fetchSlidesByTemplateId(template.id);
|
||||
if (templateSlides.length > 0) {
|
||||
queueTemplateSlideThumbnailRefresh(template.id).catch(function (error) {
|
||||
console.warn('Unable to queue template slide thumbnail refresh:', error);
|
||||
});
|
||||
}
|
||||
await notifyPlayerScreens(affectedScreens, 'refresh');
|
||||
redirectAfterSave(req, res, '/templates/' + template.id + '/edit', {
|
||||
closeUrl: '/templates',
|
||||
@@ -392,10 +594,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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
@@ -116,6 +125,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
|
||||
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
||||
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
|
||||
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
|
||||
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
|
||||
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
||||
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
||||
@@ -126,6 +136,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (durations.length && durations.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist slide data is invalid.');
|
||||
}
|
||||
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist slide data is invalid.');
|
||||
}
|
||||
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
|
||||
return res.status(400).send('Playlist schedule data is invalid.');
|
||||
}
|
||||
@@ -143,7 +156,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
seenSlideIds.add(slideId);
|
||||
|
||||
const durationRaw = Number(durations[i]);
|
||||
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10;
|
||||
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
|
||||
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
|
||||
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
|
||||
|
||||
let scheduleStartDatetime = null;
|
||||
@@ -188,6 +202,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
slideId,
|
||||
position: i,
|
||||
durationSeconds,
|
||||
useVideoDuration,
|
||||
scheduleMode,
|
||||
scheduleStartDatetime,
|
||||
scheduleEndDatetime,
|
||||
@@ -227,12 +242,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||
const item = normalizedSlides[i];
|
||||
await connection.query(
|
||||
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||
[
|
||||
playlist.id,
|
||||
item.slideId,
|
||||
item.position,
|
||||
item.durationSeconds,
|
||||
item.useVideoDuration,
|
||||
item.scheduleMode,
|
||||
item.scheduleStartDatetime,
|
||||
item.scheduleEndDatetime,
|
||||
@@ -303,11 +319,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
||||
}
|
||||
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||
const actorId = getAuditUserId(req);
|
||||
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
|
||||
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
|
||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||
await broadcastDashboardState();
|
||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||
@@ -323,7 +340,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
||||
return res.status(404).send('Playlist not found');
|
||||
}
|
||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
|
||||
const durationValue = Number(req.body.duration_seconds || 10);
|
||||
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||
const actorId = getAuditUserId(req);
|
||||
const [result] = await pool.query(
|
||||
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||
|
||||
@@ -147,7 +147,6 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
const permissionRows = await rbacData.fetchPermissions(pool);
|
||||
const selectedPermissionKeys = await rbacData.fetchRolePermissionKeys(pool, role.id);
|
||||
const selectedUserIds = await rbacData.fetchRoleUserIds(pool, role.id);
|
||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
||||
return {
|
||||
role: Object.assign({}, role, {
|
||||
permissionKeys: selectedPermissionKeys,
|
||||
@@ -155,17 +154,20 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
userCount: Number(role.user_count) || 0
|
||||
}),
|
||||
permissionGroups: buildPermissionGroups(mapPermissionsForView(permissionRows, selectedPermissionKeys)),
|
||||
users: mapUsersForView(users, selectedUserIds)
|
||||
selectedUserIds: selectedUserIds
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -248,14 +250,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
return res.status(404).send('Role not found.');
|
||||
}
|
||||
|
||||
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);
|
||||
const currentUserId = req.currentUser ? Number(req.currentUser.id) : null;
|
||||
const users = Array.isArray(viewModel.users)
|
||||
? viewModel.users.filter(function (user) {
|
||||
return Number(user && user.id) !== currentUserId;
|
||||
})
|
||||
: [];
|
||||
const data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE, search, sort, direction, {
|
||||
excludeUserId: currentUserId
|
||||
});
|
||||
const users = mapUsersForView(data.users, viewModel.selectedUserIds);
|
||||
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users));
|
||||
res.send(pages.renderRbacEditPage(viewModel.role, req.query.message ? String(req.query.message) : '', req.currentUser, viewModel.permissionGroups, users, buildPagination(data.totalItems, data.currentPage, 'page', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'users', 'User pages')));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
}
|
||||
@@ -369,7 +374,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
||||
}
|
||||
|
||||
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
|
||||
const connection = await pool.getConnection();
|
||||
try {
|
||||
await connection.beginTransaction();
|
||||
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
|
||||
await connection.commit();
|
||||
} catch (error) {
|
||||
await connection.rollback();
|
||||
throw error;
|
||||
} finally {
|
||||
connection.release();
|
||||
}
|
||||
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||
} catch (error) {
|
||||
next(error);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -27,7 +27,8 @@ module.exports = {
|
||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
|
||||
renderBackgroundTasksPage: require('./settings/background-tasks-page'),
|
||||
renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage,
|
||||
renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage,
|
||||
renderErrorPage: require('./error'),
|
||||
renderRbacPage: require('./rbac/list'),
|
||||
renderRbacAddPage: require('./rbac/add'),
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
const { renderView } = require('../../view');
|
||||
const { hasAnyPermission } = require('../../../rbac');
|
||||
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/tasks-background';
|
||||
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
|
||||
|
||||
function formatIntervalLabel(intervalMs) {
|
||||
const value = Math.max(1, Number(intervalMs) || 0);
|
||||
@@ -16,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);
|
||||
@@ -35,57 +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 = [];
|
||||
|
||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
||||
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
|
||||
pages.push({
|
||||
number: pageNumber,
|
||||
active: pageNumber === safeCurrentPage,
|
||||
url: buildQueryString(nextQuery)
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
@@ -95,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) {
|
||||
@@ -137,27 +87,63 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||
return buildQueryString(nextQuery);
|
||||
}
|
||||
|
||||
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
|
||||
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() || 'started';
|
||||
const direction = normalizeSortDirection(data && data.direction || 'desc');
|
||||
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 || '0001-01-01T00:00:00.000Z');
|
||||
}
|
||||
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,29 +185,97 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
||||
});
|
||||
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
|
||||
|
||||
return renderView('settings/background-tasks/index', {
|
||||
return renderView('settings/tasks-background/index', {
|
||||
title: 'Background tasks',
|
||||
active: 'background-tasks',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/settings/background-tasks.js'],
|
||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||
backgroundTasksMenuOpen: true,
|
||||
tasks: visibleTasksWithSources,
|
||||
recurringTasks: visibleRecurringTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||
});
|
||||
}),
|
||||
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';
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
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',
|
||||
active: 'background-tasks-scheduled',
|
||||
message: message,
|
||||
currentUser: currentUser || null,
|
||||
scripts: ['js/settings/background-tasks.js'],
|
||||
backgroundTasksMenuOpen: true,
|
||||
recurringTasks: sortedRecurringTasks.map(function (task) {
|
||||
return Object.assign({}, task, {
|
||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||
});
|
||||
}),
|
||||
summary: summary,
|
||||
canManage: canManage,
|
||||
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 })
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
renderBackgroundTasksPage: function (data, message, currentUser) {
|
||||
return buildQueuePageViewModel(data, message, currentUser);
|
||||
},
|
||||
renderBackgroundTasksScheduledPage: function (data, message, currentUser) {
|
||||
return buildScheduledPageViewModel(data, message, currentUser);
|
||||
}
|
||||
};
|
||||
@@ -17,6 +17,23 @@ function requireSettingsAccess() {
|
||||
};
|
||||
}
|
||||
|
||||
function requireScheduledSettingsAccess() {
|
||||
return function (req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (hasAnyPermission(req.currentUser, ['scheduled-tasks.read', 'scheduled-tasks.allow'])) {
|
||||
return next();
|
||||
}
|
||||
|
||||
const error = new Error('You do not have permission to access this area.');
|
||||
error.statusCode = 403;
|
||||
error.expose = true;
|
||||
next(error);
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
const pages = deps.pages;
|
||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||
@@ -25,44 +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/state', requireSettingsAccess(), 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.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() {
|
||||
@@ -82,18 +73,38 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||
};
|
||||
}
|
||||
|
||||
app.post('/settings/background-tasks/clear-finished', requireSettingsManageAccess(), function (req, res) {
|
||||
function requireRecurringTaskAccess(req, res, next) {
|
||||
if (!req.currentUser) {
|
||||
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||
}
|
||||
|
||||
if (!hasAnyPermission(req.currentUser, ['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();
|
||||
}
|
||||
|
||||
app.post('/settings/tasks-background/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.'));
|
||||
res.redirect('/settings/tasks-background?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) {
|
||||
app.post('/settings/tasks-background/: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.'));
|
||||
res.redirect('/settings/tasks-background?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/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/tasks-scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
||||
});
|
||||
};
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
const { renderView } = require('../../../view');
|
||||
|
||||
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users) {
|
||||
module.exports = function renderRbacEditPage(role, message, currentUser, permissionGroups, users, pagination) {
|
||||
return renderView('settings/rbac/edit', {
|
||||
title: 'Edit role',
|
||||
active: 'rbac',
|
||||
@@ -9,6 +9,8 @@ module.exports = function renderRbacEditPage(role, message, currentUser, permiss
|
||||
role: role,
|
||||
permissionGroups: permissionGroups || [],
|
||||
users: users || [],
|
||||
pagination: pagination || null,
|
||||
roleEditBasePath: '/rbac/' + Number(role && role.id) + '/edit',
|
||||
scripts: ['js/rbac-permissions.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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -36,6 +36,27 @@ function formatDays(daysJson) {
|
||||
return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', ');
|
||||
}
|
||||
|
||||
function normalizeScheduleDaysJson(daysJson) {
|
||||
if (Array.isArray(daysJson)) {
|
||||
return JSON.stringify(daysJson.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
|
||||
}
|
||||
|
||||
if (!daysJson) {
|
||||
return '[]';
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(daysJson);
|
||||
if (Array.isArray(parsed)) {
|
||||
return JSON.stringify(parsed.map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
|
||||
}
|
||||
} catch (_error) {
|
||||
return JSON.stringify(String(daysJson).split(',').map((day) => Number(day)).filter((day) => Number.isInteger(day) && day >= 0 && day <= 6));
|
||||
}
|
||||
|
||||
return '[]';
|
||||
}
|
||||
|
||||
function scheduleSummary(item) {
|
||||
const mode = String(item.schedule_mode || 'always');
|
||||
if (mode === 'dates') {
|
||||
@@ -61,6 +82,86 @@ function getCanvasSignature(width, height) {
|
||||
return normalizedWidth + 'x' + normalizedHeight;
|
||||
}
|
||||
|
||||
function hasVideoRegion(contentJson) {
|
||||
if (!contentJson) {
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
|
||||
const region = parsed[key];
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
|
||||
}));
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoSourcePath(slide) {
|
||||
if (!slide) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const directMediaPath = String(slide.media_path || '').trim();
|
||||
if (String(slide.media_type || '').trim().toLowerCase() === 'video' && directMediaPath) {
|
||||
return directMediaPath;
|
||||
}
|
||||
|
||||
const contentJson = slide.content_json;
|
||||
if (!contentJson) {
|
||||
return directMediaPath;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return directMediaPath;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
|
||||
});
|
||||
|
||||
return videoRegion ? String(videoRegion.value || '').trim() : directMediaPath;
|
||||
} catch (_error) {
|
||||
return directMediaPath;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoDurationSeconds(slide) {
|
||||
if (!slide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const contentJson = slide.content_json;
|
||||
if (!contentJson) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||
if (!parsed || typeof parsed !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||
});
|
||||
|
||||
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||
} catch (_error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function getVideoDurationValue(slide) {
|
||||
const storedDuration = Number(slide && slide.duration_seconds || 0);
|
||||
const videoDuration = getVideoDurationSeconds(slide);
|
||||
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
|
||||
}
|
||||
|
||||
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
|
||||
const playlistSlides = (data.playlistSlides || [])
|
||||
.filter((item) => item.playlist_id === playlist.id)
|
||||
@@ -75,6 +176,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
isFirst: index === 0,
|
||||
isLast: index === items.length - 1,
|
||||
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
|
||||
showVideoDurationButton: String(item.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(item.content_json),
|
||||
videoSourcePath: getVideoSourcePath(item),
|
||||
videoDurationSeconds: getVideoDurationSeconds(item),
|
||||
useVideoDuration: Boolean(item.use_video_duration),
|
||||
durationSeconds: getVideoDurationValue(item),
|
||||
schedule_days_json: normalizeScheduleDaysJson(item.schedule_days_json),
|
||||
scheduleSummary: scheduleSummary(item)
|
||||
}));
|
||||
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
||||
@@ -91,7 +198,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
|
||||
}).map((slide) => Object.assign({}, slide, {
|
||||
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
|
||||
isAssigned: assignedSlideIds.has(slide.id)
|
||||
isAssigned: assignedSlideIds.has(slide.id),
|
||||
showVideoDurationButton: String(slide.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(slide.content_json),
|
||||
videoSourcePath: getVideoSourcePath(slide),
|
||||
videoDurationSeconds: getVideoDurationSeconds(slide),
|
||||
useVideoDuration: Boolean(slide.use_video_duration),
|
||||
durationSeconds: getVideoDurationValue(slide)
|
||||
}));
|
||||
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
|
||||
|
||||
@@ -111,6 +223,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
||||
selectableSlides: selectableSlides,
|
||||
playlistCanvasSignature: playlistCanvasSignature,
|
||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||
scripts: ['js/lib/modal.js', 'js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
|
||||
scripts: ['js/lib/modal.js?v=20260726.7', 'js/vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
|
||||
});
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -9,6 +9,7 @@ function buildDefaultTemplate() {
|
||||
canvas_size_height: 1080,
|
||||
background_color: '#111111',
|
||||
background_image_path: '',
|
||||
region_usage: [],
|
||||
regions: []
|
||||
};
|
||||
}
|
||||
|
||||
@@ -6,6 +6,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
||||
name: template.name,
|
||||
canvas_size_width: template.canvas_size_width,
|
||||
canvas_size_height: template.canvas_size_height,
|
||||
slideCount: template.slide_count !== undefined
|
||||
? Number(template.slide_count) || 0
|
||||
: 0,
|
||||
regionCount: template.region_count !== undefined
|
||||
? Number(template.region_count) || 0
|
||||
: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
||||
|
||||
@@ -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);
|
||||
|
||||
+1
-1
@@ -110,7 +110,7 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
|
||||
});
|
||||
|
||||
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||
Handlebars.registerPartial('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];
|
||||
|
||||
@@ -22,11 +22,11 @@
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>API URL</th>
|
||||
<th>Refresh interval</th>
|
||||
<th>Last pulled</th>
|
||||
<th>Last response</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="url">API URL</th>
|
||||
<th data-table-sort-key="interval">Refresh interval</th>
|
||||
<th data-table-sort-key="last_pulled">Last pulled</th>
|
||||
<th data-table-sort-key="last_response">Last response</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -22,10 +22,10 @@
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Feed URL</th>
|
||||
<th>Refresh interval</th>
|
||||
<th>Items pulled</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="url">Feed URL</th>
|
||||
<th data-table-sort-key="interval">Refresh interval</th>
|
||||
<th data-table-sort-key="items">Items pulled</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,262 +0,0 @@
|
||||
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
||||
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Background tasks</h2>
|
||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-filters">
|
||||
{{#if hasActiveFilters}}
|
||||
<div class="alert alert-light border d-flex flex-wrap align-items-center justify-content-between gap-2 py-2 mb-3">
|
||||
<div class="small text-muted">
|
||||
{{#if activeSourceLabel}}Showing results from {{activeSourceLabel}}.{{/if}}
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{clearFiltersUrl}}" data-background-tasks-nav="clear">Clear filters</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-summary" class="row g-3 mb-4">
|
||||
{{#each statusCards}}
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">{{label}}</div>
|
||||
<div class="fs-3 fw-semibold">{{count}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Task queue</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
|
||||
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
|
||||
</div>
|
||||
{{#if canManage}}
|
||||
{{#if canClearFinished}}
|
||||
<form method="post" action="/settings/background-tasks/clear-finished">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Category</th>
|
||||
<th>Status</th>
|
||||
<th>Queued</th>
|
||||
<th>Started</th>
|
||||
<th>Finished</th>
|
||||
<th>Source</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if tasks.length}}
|
||||
{{#each tasks}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
{{#if key}}
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
{{/if}}
|
||||
{{#if errorMessage}}
|
||||
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Category">{{category}}</td>
|
||||
<td data-label="Status">
|
||||
{{#if (eq status 'queued')}}
|
||||
<span class="badge text-bg-secondary">Queued</span>
|
||||
{{else if (eq status 'running')}}
|
||||
<span class="badge text-bg-primary">Running</span>
|
||||
{{else if (eq status 'completed')}}
|
||||
<span class="badge text-bg-success">Completed</span>
|
||||
{{else if (eq status 'failed')}}
|
||||
<span class="badge text-bg-danger">Failed</span>
|
||||
{{else if (eq status 'canceled')}}
|
||||
<span class="badge text-bg-warning">Canceled</span>
|
||||
{{else}}
|
||||
<span class="badge text-bg-light text-dark">{{status}}</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Queued">
|
||||
{{#if createdAt}}
|
||||
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Started">
|
||||
{{#if startedAt}}
|
||||
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Finished">
|
||||
{{#if finishedAt}}
|
||||
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
{{#if canManage}}
|
||||
{{#if (eq status 'queued')}}
|
||||
<form method="post" action="/settings/background-tasks/{{id}}/cancel">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm-message="Cancel this queued task?">Cancel</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{#if (eq status 'failed')}}
|
||||
<form method="post" action="/settings/background-tasks/{{id}}/retry">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">Retry</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="8" class="empty">No background tasks are queued right now.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{#if pagination.hasMultiplePages}}
|
||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="text-muted small">
|
||||
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
|
||||
</div>
|
||||
<nav aria-label="Background task pages">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||
</li>
|
||||
{{#each pagination.pages}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Scheduled refreshes</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-recurring-tools">
|
||||
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Task</th>
|
||||
<th>Interval</th>
|
||||
<th>Next run</th>
|
||||
<th>Last run</th>
|
||||
<th>Last result</th>
|
||||
<th>Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if recurringTasks.length}}
|
||||
{{#each recurringTasks}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
</td>
|
||||
<td data-label="Interval">{{intervalLabel}}</td>
|
||||
<td data-label="Next run">
|
||||
{{#if nextRunAt}}
|
||||
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last run">
|
||||
{{#if lastRunAt}}
|
||||
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last result">
|
||||
{{#if lastStatus}}
|
||||
<span class="badge {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
|
||||
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="6" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{#if recurringPagination.hasMultiplePages}}
|
||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="text-muted small">
|
||||
Showing {{recurringPagination.startItem}}-{{recurringPagination.endItem}} of {{recurringPagination.totalItems}} scheduled refreshes
|
||||
</div>
|
||||
<nav aria-label="Scheduled refresh pages">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {{#unless recurringPagination.hasPrevious}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if recurringPagination.hasPrevious}}/settings/background-tasks{{recurringPagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||
</li>
|
||||
{{#each recurringPagination.pages}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{/each}}
|
||||
<li class="page-item {{#unless recurringPagination.hasNext}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if recurringPagination.hasNext}}/settings/background-tasks{{recurringPagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -25,7 +25,7 @@
|
||||
<textarea id="role-description-edit" name="description" class="form-control" rows="5">{{role.description}}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer d-flex justify-content-end gap-2 flex-wrap">
|
||||
<div class="card-footer d-flex justify-content-end flex-wrap">
|
||||
<div class="btn-group" role="group" aria-label="Role actions">
|
||||
{{{saveActionButtons formId="role-edit-form" saveLabel="Save" saveAndCloseLabel="Save and Close" saveAndCloseValue="close" showSaveAndNew=false}}}
|
||||
</div>
|
||||
@@ -35,45 +35,50 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card card-outline card-secondary admin-form-card">
|
||||
<div class="card-header">
|
||||
<div class="card card-outline card-secondary admin-form-card" data-table-search-container>
|
||||
<div class="card-header d-flex flex-wrap align-items-center justify-content-between">
|
||||
<h3 class="card-title">Users</h3>
|
||||
<div class="input-group input-group-sm flex-shrink-1 ms-auto" style="width: min(14rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="card-body table-responsive p-0">
|
||||
<div class="px-3 pt-3">
|
||||
<p class="text-body-secondary mb-3">Choose the users who should belong to this role.</p>
|
||||
</div>
|
||||
{{#if users.length}}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm align-middle mb-0 w-100">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 1%" scope="col">Select</th>
|
||||
<th scope="col">Name</th>
|
||||
<th scope="col">Username</th>
|
||||
<th scope="col">Roles</th>
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 1%" scope="col" data-sortable="false">Select</th>
|
||||
<th scope="col" data-table-sort-key="user">Username</th>
|
||||
<th scope="col" data-table-sort-key="name">Name</th>
|
||||
<th scope="col" data-table-sort-key="roles">Roles</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if users.length}}
|
||||
{{#each users}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Select">
|
||||
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
</td>
|
||||
<td data-label="Username">
|
||||
<div class="user-cell">
|
||||
<div>{{username}}</div>
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Name">{{#if name}}{{name}}{{else}}-{{/if}}</td>
|
||||
<td data-label="Roles" class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#each users}}
|
||||
<tr>
|
||||
<td>
|
||||
<input class="form-check-input" type="checkbox" name="user_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||
</td>
|
||||
<td>{{name}}</td>
|
||||
<td class="text-body-secondary">{{username}}</td>
|
||||
<td class="text-body-secondary">{{#if roleNames}}{{roleNames}}{{else}}No roles assigned{{/if}}</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{else}}
|
||||
<div class="px-3 pb-3">
|
||||
<div class="alert alert-warning mb-0">Create at least one user before assigning this role.</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default><td colspan="4" class="empty">No users found.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath=roleEditBasePath}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,10 +24,10 @@
|
||||
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Role</th>
|
||||
<th>Description</th>
|
||||
<th>Users</th>
|
||||
<th>Permissions</th>
|
||||
<th data-table-sort-key="role">Role</th>
|
||||
<th data-table-sort-key="description">Description</th>
|
||||
<th data-table-sort-key="users">Users</th>
|
||||
<th data-table-sort-key="permissions">Permissions</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Background tasks</h2>
|
||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-filters">
|
||||
{{#if hasActiveFilters}}
|
||||
<div class="alert alert-light border d-flex flex-wrap align-items-center justify-content-between gap-2 py-2 mb-3">
|
||||
<div class="small text-muted">
|
||||
{{#if activeSourceLabel}}Showing results from {{activeSourceLabel}}.{{/if}}
|
||||
</div>
|
||||
<a class="btn btn-outline-secondary btn-sm" href="{{clearFiltersUrl}}" data-background-tasks-nav="clear">Clear filters</a>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-summary" class="row g-3 mb-4">
|
||||
{{#each statusCards}}
|
||||
<div class="col-6 col-xl-3">
|
||||
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
|
||||
<div class="card-body">
|
||||
<div class="text-muted small text-uppercase">{{label}}</div>
|
||||
<div class="fs-3 fw-semibold">{{count}}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{/each}}
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Task Queue</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
|
||||
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
|
||||
</div>
|
||||
{{#if canManage}}
|
||||
{{#if canClearFinished}}
|
||||
<form method="post" action="/settings/tasks-background/clear-finished">
|
||||
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
||||
</form>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="task">Task</th>
|
||||
<th data-table-sort-key="category">Category</th>
|
||||
<th data-table-sort-key="status">Status</th>
|
||||
<th data-table-sort-key="queued">Queued</th>
|
||||
<th data-table-sort-key="started">Started</th>
|
||||
<th data-table-sort-key="finished">Finished</th>
|
||||
<th data-table-sort-key="source">Source</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if tasks.length}}
|
||||
{{#each tasks}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
{{#if key}}
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
{{/if}}
|
||||
{{#if errorMessage}}
|
||||
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Category">{{category}}</td>
|
||||
<td data-label="Status">
|
||||
{{#if (eq status 'queued')}}
|
||||
<span class="badge text-bg-secondary">Queued</span>
|
||||
{{else if (eq status 'running')}}
|
||||
<span class="badge text-bg-primary">Running</span>
|
||||
{{else if (eq status 'completed')}}
|
||||
<span class="badge text-bg-success">Completed</span>
|
||||
{{else if (eq status 'failed')}}
|
||||
<span class="badge text-bg-danger">Failed</span>
|
||||
{{else if (eq status 'canceled')}}
|
||||
<span class="badge text-bg-warning">Canceled</span>
|
||||
{{else}}
|
||||
<span class="badge text-bg-light text-dark">{{status}}</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Queued">
|
||||
{{#if createdAt}}
|
||||
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Started">
|
||||
{{#if startedAt}}
|
||||
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Finished">
|
||||
{{#if finishedAt}}
|
||||
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
|
||||
{{else}}
|
||||
-
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="7" class="empty">No background tasks are queued right now.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{{> table-pagination pagination=pagination basePath="/settings/tasks-background"}}
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,85 @@
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h2>Scheduled tasks</h2>
|
||||
<p>Use this page to view and manage recurring tasks.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
|
||||
<div class="card-header d-flex align-items-center">
|
||||
<h3 class="card-title mb-0">Scheduled Tasks</h3>
|
||||
<div class="ms-auto d-flex flex-nowrap align-items-center background-tasks-recurring-tools">
|
||||
<div class="input-group input-group-sm background-tasks-recurring-search">
|
||||
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th data-table-sort-key="task">Task</th>
|
||||
<th data-table-sort-key="interval">Interval</th>
|
||||
<th data-table-sort-key="next_run">Next run</th>
|
||||
<th data-table-sort-key="last_run">Last run</th>
|
||||
<th data-table-sort-key="last_result">Last result</th>
|
||||
<th data-table-sort-key="source">Source</th>
|
||||
{{#if canManage}}<th>Actions</th>{{/if}}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{{#if recurringTasks.length}}
|
||||
{{#each recurringTasks}}
|
||||
<tr data-table-search-row>
|
||||
<td data-label="Task">
|
||||
<div class="fw-semibold">{{title}}</div>
|
||||
<div class="text-muted small text-break">{{key}}</div>
|
||||
</td>
|
||||
<td data-label="Interval">{{intervalLabel}}</td>
|
||||
<td data-label="Next run">
|
||||
{{#if nextRunAt}}
|
||||
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last run">
|
||||
{{#if lastRunAt}}
|
||||
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||
{{else}}-{{/if}}
|
||||
</td>
|
||||
<td data-label="Last result">
|
||||
{{#if lastStatus}}
|
||||
<span class="badge text-capitalize {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
|
||||
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
<td data-label="Source">
|
||||
{{#if metadata.sourceName}}
|
||||
<div>{{metadata.sourceName}}</div>
|
||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||
{{else}}
|
||||
<span class="empty">-</span>
|
||||
{{/if}}
|
||||
</td>
|
||||
{{#if ../canManage}}
|
||||
<td data-label="Actions">
|
||||
<div class="d-flex flex-wrap gap-2">
|
||||
<form method="post" action="/settings/tasks-scheduled/recurring/{{key}}/run">
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
{{/if}}
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr data-table-search-empty-default>
|
||||
<td colspan="{{#if canManage}}7{{else}}6{{/if}}" class="empty">No scheduled refreshes are registered yet.</td>
|
||||
</tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
@@ -22,9 +22,9 @@
|
||||
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>User</th>
|
||||
<th>Name</th>
|
||||
<th>Roles</th>
|
||||
<th data-table-sort-key="user">User</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="roles">Roles</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html data-bs-theme="dark" style="color-scheme: dark;">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
@@ -82,9 +82,12 @@
|
||||
{{/if}}
|
||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||
<script src="/assets/js/admin/toast.js"></script>
|
||||
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||
<script src="/assets/js/toast.js"></script>
|
||||
<script src="/assets/js/table/table-sort.js"></script>
|
||||
<script src="/assets/js/table/table-search.js"></script>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/admin/system-status.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
{{#if scripts.length}}
|
||||
{{#each scripts}}
|
||||
<script src="/assets/{{this}}"></script>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<!doctype html>
|
||||
<html>
|
||||
<html data-bs-theme="dark" style="color-scheme: dark;">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||
@@ -17,13 +17,13 @@
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
</head>
|
||||
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}">
|
||||
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"{{#if authShell}} data-bs-theme="dark" style="color-scheme: dark;"{{else if errorShell}} data-bs-theme="dark" style="color-scheme: dark;"{{/if}}>
|
||||
{{#if errorShell}}
|
||||
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
|
||||
{{{body}}}
|
||||
</main>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/admin/system-status.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
{{else if authShell}}
|
||||
<main class="login-page d-flex align-items-center justify-content-center min-vh-100 p-3">
|
||||
<div class="login-box">
|
||||
@@ -31,15 +31,28 @@
|
||||
</div>
|
||||
</main>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
<script src="/assets/js/admin/system-status.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
{{else}}
|
||||
<div id="app-toast-container" class="toast-container position-fixed top-0 end-0 p-3">
|
||||
<div id="app-toast-container" class="toast-container app-toast-container position-fixed end-0 p-3">
|
||||
{{#if message}}
|
||||
<div class="toast align-items-center text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}} border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}}">
|
||||
<div class="d-flex">
|
||||
<div class="toast-body">{{message}}</div>
|
||||
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
|
||||
<div class="toast shadow-lg border-0" id="app-toast" role="status" aria-live="polite" aria-atomic="true" data-bs-autohide="true" data-bs-delay="4000" data-toast-variant="{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}}" data-toast-created-at="">
|
||||
<div class="toast-header border-0 text-bg-{{#if messageVariant}}{{messageVariant}}{{else}}info{{/if}}">
|
||||
{{#if (eq messageVariant 'success')}}
|
||||
<i class="bi bi-check-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'warning')}}
|
||||
<i class="bi bi-exclamation-triangle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'danger')}}
|
||||
<i class="bi bi-x-circle-fill me-2"></i>
|
||||
{{else if (eq messageVariant 'primary')}}
|
||||
<i class="bi bi-bell-fill me-2"></i>
|
||||
{{else}}
|
||||
<i class="bi bi-info-circle-fill me-2"></i>
|
||||
{{/if}}
|
||||
<strong class="me-auto">Pulse</strong>
|
||||
<small class="toast-time text-nowrap">just now</small>
|
||||
<button type="button" class="btn-close btn-close-white ms-2" data-bs-dismiss="toast" aria-label="Dismiss notification"></button>
|
||||
</div>
|
||||
<div class="toast-body">{{message}}</div>
|
||||
</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
@@ -213,7 +226,7 @@
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'users.read' 'rbac.read')}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read')}}
|
||||
<li class="nav-header">SETTINGS</li>
|
||||
{{#if (hasPermission currentUser 'users.read')}}
|
||||
<li class="nav-item">
|
||||
@@ -231,12 +244,33 @@
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
||||
<i class="nav-icon bi bi-list-task"></i>
|
||||
<p>Background tasks</p>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
|
||||
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
|
||||
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
|
||||
<i class="nav-icon bi bi-pen"></i>
|
||||
<p>
|
||||
Tasks
|
||||
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||
</p>
|
||||
</a>
|
||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||
<ul class="nav nav-treeview">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/tasks-background">
|
||||
<i class="nav-icon bi bi-list-ol"></i>
|
||||
<p>Task queue</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/tasks-scheduled">
|
||||
<i class="nav-icon bi bi-calendar-event"></i>
|
||||
<p>Scheduled tasks</p>
|
||||
</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
</ul>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -263,8 +297,10 @@
|
||||
</dialog>
|
||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||
<script src="/assets/js/admin/toast.js"></script>
|
||||
<script src="/assets/js/table-sort.js"></script>
|
||||
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||
<script src="/assets/js/toast.js"></script>
|
||||
<script src="/assets/js/table/table-sort.js"></script>
|
||||
<script src="/assets/js/table/table-search.js"></script>
|
||||
<script src="/assets/js/admin/admin-page.js"></script>
|
||||
{{#if scripts.length}}
|
||||
{{#each scripts}}
|
||||
@@ -275,7 +311,7 @@
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
{{/if}}
|
||||
<script src="/assets/js/admin/system-status.js"></script>
|
||||
<script src="/assets/js/system-status.js"></script>
|
||||
{{/if}}
|
||||
</body>
|
||||
</html>
|
||||
+12
-6
@@ -1,17 +1,23 @@
|
||||
{{#if pagination.hasMultiplePages}}
|
||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||
<div class="card-footer d-flex flex-wrap align-items-center">
|
||||
<div class="text-muted small">
|
||||
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
|
||||
</div>
|
||||
<nav aria-label="{{pagination.ariaLabel}}">
|
||||
<nav class="ms-auto" aria-label="{{pagination.ariaLabel}}">
|
||||
<ul class="pagination pagination-sm mb-0">
|
||||
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||
</li>
|
||||
{{#each pagination.pages}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{#if ellipsis}}
|
||||
<li class="page-item disabled" aria-hidden="true">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{{else}}
|
||||
<li class="page-item {{#if active}}active{{/if}}">
|
||||
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
|
||||
</li>
|
||||
{{/if}}
|
||||
{{/each}}
|
||||
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
||||
<a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||
@@ -19,4 +25,4 @@
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
{{/if}}
|
||||
{{/if}}
|
||||
@@ -22,9 +22,9 @@
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Dimensions</th>
|
||||
<th>Templates</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="dimensions">Dimensions</th>
|
||||
<th data-table-sort-key="templates">Templates</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -19,12 +19,12 @@
|
||||
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Client</th>
|
||||
<th>Current Screen</th>
|
||||
<th>Current Slide</th>
|
||||
<th>IP</th>
|
||||
<th>Viewport</th>
|
||||
<th>Connected/Updated</th>
|
||||
<th data-table-sort-key="client">Client</th>
|
||||
<th data-table-sort-key="screen">Current Screen</th>
|
||||
<th data-table-sort-key="slide">Current Slide</th>
|
||||
<th data-table-sort-key="ip">IP</th>
|
||||
<th data-table-sort-key="viewport">Viewport</th>
|
||||
<th data-table-sort-key="connected">Connected/Updated</th>
|
||||
{{#if (hasPermission currentUser 'clients.allow')}}<th>Actions</th>{{/if}}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
<div>
|
||||
<span class="dashboard-hero-kicker">Live overview</span>
|
||||
<h3 class="dashboard-hero-title">Keep the control surface focused on live state and actions.</h3>
|
||||
<p class="dashboard-hero-copy">Use the cards below for the current totals, then jump to the screens table when you need a deeper look at playback and connection state.</p>
|
||||
<p class="dashboard-hero-copy">Use the cards below for the current totals, then open the screen snapshot when you want a quick read on playlist assignment and live connections.</p>
|
||||
</div>
|
||||
<div class="dashboard-hero-stats">
|
||||
{{#if (hasPermission currentUser "playlists.read")}}
|
||||
@@ -63,6 +63,22 @@
|
||||
><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload all clients</button>
|
||||
<span class="dashboard-action-help">Restart every connected player page.</span>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/commands"
|
||||
class="dashboard-action-form"
|
||||
data-confirm-message="Pause all connected clients?"
|
||||
data-async-command
|
||||
>
|
||||
<input type="hidden" name="command" value="pause" />
|
||||
<input type="hidden" name="paused" value="true" />
|
||||
<button
|
||||
type="submit"
|
||||
class="btn btn-info dashboard-action-button"
|
||||
id="dashboard-pause-all-button"
|
||||
><i class="bi bi-pause-fill me-1" aria-hidden="true"></i>Pause all clients</button>
|
||||
<span class="dashboard-action-help">Freeze playback on every connected player.</span>
|
||||
</form>
|
||||
<form
|
||||
method="post"
|
||||
action="/commands"
|
||||
@@ -91,46 +107,49 @@
|
||||
{{#if (hasPermission currentUser "screens.read")}}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card card-outline card-secondary dashboard-table-card">
|
||||
<div class="card-header">
|
||||
<div class="card card-outline card-secondary dashboard-screen-card-shell">
|
||||
<div class="card-header dashboard-screen-card-header">
|
||||
<div class="dashboard-card-heading">
|
||||
<h3 class="card-title">Current screens</h3>
|
||||
<h3 class="card-title">Screen snapshot</h3>
|
||||
<p class="dashboard-card-subtitle">Player URL, playlist assignment,
|
||||
and live connection count.</p>
|
||||
and live connection count without the spreadsheet feel.</p>
|
||||
</div>
|
||||
<a class="btn btn-sm btn-outline-primary" href="/screens">Manage screens</a>
|
||||
</div>
|
||||
<div class="card-body table-responsive p-0">
|
||||
<table
|
||||
id="dashboard-screens-table"
|
||||
class="table table-striped w-100 mb-0"
|
||||
>
|
||||
<thead><tr><th>Name</th><th>Player URL</th><th>Playlist</th><th
|
||||
>Connected clients</th></tr></thead>
|
||||
<tbody>
|
||||
{{#if screens.length}}
|
||||
{{#each screens}}
|
||||
<tr data-client-key="{{id}}">
|
||||
<td data-label="Name">{{name}}</td>
|
||||
<td data-label="Player URL"><a
|
||||
href="{{playerUrl slug}}"
|
||||
target="_blank"
|
||||
>{{playerUrl slug}}</a></td>
|
||||
<td data-label="Playlist">{{playlist_name}}</td>
|
||||
<td data-label="Connected clients">
|
||||
<div class="card-body">
|
||||
<div id="dashboard-screens-grid" class="dashboard-screen-grid">
|
||||
{{#if screens.length}}
|
||||
{{#each screens}}
|
||||
<article class="dashboard-screen-tile" data-screen-key="{{id}}">
|
||||
<div class="dashboard-screen-tile-top">
|
||||
<div class="dashboard-screen-tile-text">
|
||||
<h4 class="dashboard-screen-name">{{name}}</h4>
|
||||
<a class="dashboard-screen-link" href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a>
|
||||
</div>
|
||||
<span class="dashboard-screen-pill {{#if player_connection_count}}is-live{{else}}is-idle{{/if}}">
|
||||
{{#if player_connection_count}}
|
||||
<div class="connection-count">{{player_connection_count}}
|
||||
connected</div>
|
||||
{{player_connection_count}} live
|
||||
{{else}}
|
||||
<span class="empty">No clients connected.</span>
|
||||
No clients
|
||||
{{/if}}
|
||||
</td>
|
||||
</tr>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<tr><td colspan="4" class="empty">No screens yet.</td></tr>
|
||||
{{/if}}
|
||||
</tbody>
|
||||
</table>
|
||||
</span>
|
||||
</div>
|
||||
<dl class="dashboard-screen-meta">
|
||||
<div>
|
||||
<dt>Playlist</dt>
|
||||
<dd>{{#if playlist_name}}{{playlist_name}}{{else}}Unassigned{{/if}}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Connections</dt>
|
||||
<dd>{{#if player_connection_count}}{{player_connection_count}} connected{{else}}No clients connected{{/if}}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</article>
|
||||
{{/each}}
|
||||
{{else}}
|
||||
<div class="empty dashboard-screen-empty">No screens yet.</div>
|
||||
{{/if}}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -83,7 +83,7 @@
|
||||
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}">
|
||||
{{#if playlistSlides.length}}
|
||||
{{#each playlistSlides}}
|
||||
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}">
|
||||
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}" data-media-type="{{media_type}}" data-media-path="{{media_path}}" data-video-source-path="{{videoSourcePath}}" data-video-duration-seconds="{{videoDurationSeconds}}" data-use-video-duration="{{#if useVideoDuration}}true{{else}}false{{/if}}">
|
||||
<td class="playlist-order-cell" data-label="Order">
|
||||
<div class="playlist-order-cell-inner">
|
||||
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder">
|
||||
@@ -123,7 +123,16 @@
|
||||
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
|
||||
</td>
|
||||
<td data-label="Duration">
|
||||
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" />
|
||||
<div class="playlist-duration-field">
|
||||
<input name="duration_seconds[]" type="text" inputmode="decimal" value="{{durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if useVideoDuration}}disabled{{/if}} />
|
||||
<input type="hidden" name="use_video_duration[]" value="{{#if useVideoDuration}}1{{else}}0{{/if}}" form="playlist-edit-form" />
|
||||
{{#if useVideoDuration}}
|
||||
<input type="hidden" name="duration_seconds[]" value="{{durationSeconds}}" form="playlist-edit-form" data-video-duration-mirror />
|
||||
{{/if}}
|
||||
{{#if showVideoDurationButton}}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration{{#if useVideoDuration}} active{{/if}}" data-use-video-duration aria-pressed="{{#if useVideoDuration}}true{{else}}false{{/if}}">{{#if useVideoDuration}}Use Video Duration{{else}}Use video duration{{/if}}</button>
|
||||
{{/if}}
|
||||
</div>
|
||||
</td>
|
||||
<td data-label="Actions">
|
||||
<div class="actions playlist-item-actions">
|
||||
|
||||
@@ -22,8 +22,8 @@
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Slides</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="slides">Slides</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
@@ -22,9 +22,9 @@
|
||||
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Player URL</th>
|
||||
<th>Playlist</th>
|
||||
<th data-table-sort-key="name">Name</th>
|
||||
<th data-table-sort-key="url">Player URL</th>
|
||||
<th data-table-sort-key="playlist">Playlist</th>
|
||||
<th>Connected clients</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user