Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9c5cabbd13 | ||
|
|
815c808d73 | ||
|
|
4afaeaafb1 | ||
|
|
ef9cdd2986 | ||
|
|
2176fb9042 | ||
|
|
6cb45d7839 | ||
|
|
eb1f33d82f | ||
|
|
07ec17ad91 | ||
|
|
b5fbf79af7 | ||
|
|
4ca4da473f | ||
|
|
f4de31269d | ||
|
|
235d2da6aa | ||
|
|
d24c9c4035 | ||
|
|
04ddec50ad | ||
|
|
47613e61a2 | ||
|
|
bd130a1070 | ||
|
|
d48f0e779f | ||
|
|
90ec3bb2df | ||
|
|
0ca20035cd | ||
|
|
c18f597068 | ||
|
|
0e89892c94 |
@@ -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.
|
||||||
+121
@@ -6,6 +6,127 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- No unreleased changes recorded yet.
|
- No unreleased changes recorded yet.
|
||||||
|
|
||||||
|
## 1.5.14 - 2026-07-26
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Dashboard client action icons now render valid Bootstrap Icon classes without duplicating the `bi-` prefix.
|
||||||
|
|
||||||
|
## 1.5.13 - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Shared table helpers for pagination, search, and sorting across the admin and signage views.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Refreshed the shared admin shell and moved common UI behavior into shared layout and helper modules.
|
||||||
|
- Reworked dashboard, background tasks, RBAC, and signage list pages to use the newer shared table and state flow.
|
||||||
|
- Updated the admin and player branding assets and tightened the page scripts around the new layout structure.
|
||||||
|
|
||||||
|
## 1.5.12 - 2026-07-26
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
- User creation now keeps the role checkboxes inside the add-user form so selected roles are submitted correctly.
|
||||||
|
|
||||||
|
## 1.5.8 - 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
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Playlist slide picker now uses a button to reveal already added slides, while still defaulting to showing only available slides.
|
||||||
|
- Already-added slides in the picker are now labeled and visually muted to make them easier to spot.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Thumbnails in slide and playlist tables now follow the slide canvas ratio and cap at 5.5rem tall.
|
||||||
|
- Thumbnail-only table columns are no longer sortable.
|
||||||
|
|
||||||
|
## 1.5.5 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed a migration gap that could leave `slides.thumbnail_path` missing on upgraded databases.
|
||||||
|
|
||||||
|
## 1.5.4 - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Cropper.js has been added to aid with image uploading.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Upgraded Multer to 2.x to remove the deprecated 1.x dependency warning.
|
||||||
|
- Tightened a few admin UI details, including slide editor and RBAC list spacing/typography adjustments.
|
||||||
|
|
||||||
|
## 1.5.3 - 2026-07-25
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- A modal-based slide picker for playlist editing with search, multi-select, and slide thumbnails.
|
||||||
|
- Reusable modal and table-pagination helpers for the admin UI.
|
||||||
|
- Slide thumbnail previews in playlist schedules, with image fallbacks when a thumbnail is missing.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- RTMP playback now probes session readiness more aggressively and retries startup before surfacing failures.
|
||||||
|
- Playlist editing now includes richer scheduling controls, live slide counts, and support for skipping unavailable RTMP slides.
|
||||||
|
- Admin lists and background task pages now preserve filter and pagination state during AJAX navigation.
|
||||||
|
- Shared admin shell styling and helpers were refreshed to support the newer list, modal, and pagination UI.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Player upload sync and route handling now degrade more gracefully when backend services are unavailable.
|
||||||
|
- RTMP and playlist playback now recover more cleanly when a stream fails to initialize or respond in time.
|
||||||
|
|
||||||
## 1.5.2 - 2026-07-25
|
## 1.5.2 - 2026-07-25
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@ FROM node:24-alpine
|
|||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
RUN apk add --no-cache ffmpeg
|
RUN apk add --no-cache ffmpeg chromium nss freetype harfbuzz ttf-freefont
|
||||||
|
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev --no-audit --no-fund
|
RUN npm install --omit=dev --no-audit --no-fund
|
||||||
|
|||||||
Generated
+3462
File diff suppressed because it is too large
Load Diff
+7
-3
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.2",
|
"version": "1.5.14",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
@@ -16,14 +16,18 @@
|
|||||||
"dev:player": "nodemon -r dotenv/config src/player.js"
|
"dev:player": "nodemon -r dotenv/config src/player.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
|
"cropperjs": "^1.6.2",
|
||||||
"dotenv": "^17.4.2",
|
"dotenv": "^17.4.2",
|
||||||
"express": "^4.21.2",
|
"express": "^4.21.2",
|
||||||
"hls.js": "^1.5.15",
|
|
||||||
"handlebars": "^4.7.8",
|
"handlebars": "^4.7.8",
|
||||||
"multer": "^1.4.5-lts.1",
|
"hls.js": "^1.5.15",
|
||||||
|
"multer": "^2.2.0",
|
||||||
"mysql2": "^3.14.3",
|
"mysql2": "^3.14.3",
|
||||||
|
"puppeteer-core": "^24.16.0",
|
||||||
"qrcode": "^1.5.4",
|
"qrcode": "^1.5.4",
|
||||||
|
"sharp": "^0.35.3",
|
||||||
"ws": "^8.21.0"
|
"ws": "^8.21.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
@@ -2,6 +2,18 @@ const db = require('./db');
|
|||||||
const data = require('./data');
|
const data = require('./data');
|
||||||
const player = require('./player/render');
|
const player = require('./player/render');
|
||||||
|
|
||||||
|
function getSearchQuery(req) {
|
||||||
|
return String(req && req.query && req.query.search || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortQuery(req) {
|
||||||
|
return String(req && req.query && req.query.sort || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSortDirectionQuery(req) {
|
||||||
|
return String(req && req.query && req.query.direction || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
createPool: db.createPool,
|
createPool: db.createPool,
|
||||||
ensureSchema: db.ensureSchema,
|
ensureSchema: db.ensureSchema,
|
||||||
@@ -9,12 +21,22 @@ module.exports = {
|
|||||||
uniqueScreenSlug: data.uniqueScreenSlug,
|
uniqueScreenSlug: data.uniqueScreenSlug,
|
||||||
parseJsonSafe: data.parseJsonSafe,
|
parseJsonSafe: data.parseJsonSafe,
|
||||||
fetchAdminData: data.fetchAdminData,
|
fetchAdminData: data.fetchAdminData,
|
||||||
|
fetchPlaylistsPage: data.fetchPlaylistsPage,
|
||||||
|
fetchSlidesPage: data.fetchSlidesPage,
|
||||||
|
fetchTemplatesPage: data.fetchTemplatesPage,
|
||||||
|
fetchCanvasSizesPage: data.fetchCanvasSizesPage,
|
||||||
|
fetchScreensPage: data.fetchScreensPage,
|
||||||
|
getSearchQuery: getSearchQuery,
|
||||||
|
getSortQuery: getSortQuery,
|
||||||
|
getSortDirectionQuery: getSortDirectionQuery,
|
||||||
fetchPlaylistById: data.fetchPlaylistById,
|
fetchPlaylistById: data.fetchPlaylistById,
|
||||||
fetchApiSourcesData: data.fetchApiSourcesData,
|
fetchApiSourcesData: data.fetchApiSourcesData,
|
||||||
|
fetchApiSourcesPage: data.fetchApiSourcesPage,
|
||||||
fetchApiSourceById: data.fetchApiSourceById,
|
fetchApiSourceById: data.fetchApiSourceById,
|
||||||
fetchApiSourceResponse: data.fetchApiSourceResponse,
|
fetchApiSourceResponse: data.fetchApiSourceResponse,
|
||||||
buildApiSourcePayload: data.buildApiSourcePayload,
|
buildApiSourcePayload: data.buildApiSourcePayload,
|
||||||
fetchRssFeedsData: data.fetchRssFeedsData,
|
fetchRssFeedsData: data.fetchRssFeedsData,
|
||||||
|
fetchRssFeedsPage: data.fetchRssFeedsPage,
|
||||||
fetchRssFeedById: data.fetchRssFeedById,
|
fetchRssFeedById: data.fetchRssFeedById,
|
||||||
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
|
fetchRssFeedItemsByFeedId: data.fetchRssFeedItemsByFeedId,
|
||||||
normalizeRssFeedItem: data.normalizeRssFeedItem,
|
normalizeRssFeedItem: data.normalizeRssFeedItem,
|
||||||
|
|||||||
+139
-4
@@ -1,5 +1,7 @@
|
|||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
async function fetchAdminData(pool) {
|
async function fetchAdminData(pool) {
|
||||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||||
const [templates] = await pool.query(`
|
const [templates] = await pool.query(`
|
||||||
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
@@ -10,7 +12,7 @@ async function fetchAdminData(pool) {
|
|||||||
`);
|
`);
|
||||||
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
||||||
const [slides] = await pool.query(`
|
const [slides] = await pool.query(`
|
||||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, 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
|
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
FROM slides s
|
FROM slides s
|
||||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
@@ -23,7 +25,7 @@ async function fetchAdminData(pool) {
|
|||||||
ORDER BY s.id DESC
|
ORDER BY s.id DESC
|
||||||
`);
|
`);
|
||||||
const [playlistSlides] = await pool.query(`
|
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, 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
|
FROM playlist_slides ps
|
||||||
JOIN slides sl ON sl.id = ps.slide_id
|
JOIN slides sl ON sl.id = ps.slide_id
|
||||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||||
@@ -33,6 +35,139 @@ async function fetchAdminData(pool) {
|
|||||||
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchPlaylistsPage(pool, page, pageSize, 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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ playlists: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ slides: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ templates: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ screens: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchAdminData
|
fetchAdminData,
|
||||||
|
fetchPlaylistsPage,
|
||||||
|
fetchSlidesPage,
|
||||||
|
fetchTemplatesPage,
|
||||||
|
fetchCanvasSizesPage,
|
||||||
|
fetchScreensPage
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
function normalizeUpdateIntervalUnit(value) {
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
const unit = String(value || '').trim().toLowerCase();
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
@@ -14,6 +15,30 @@ async function fetchApiSourcesData(pool) {
|
|||||||
return { apiSources: apiSources };
|
return { apiSources: apiSources };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ apiSources: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchApiSourceById(pool, id) {
|
async function fetchApiSourceById(pool, id) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'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 WHERE id = ?',
|
'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 WHERE id = ?',
|
||||||
@@ -144,6 +169,7 @@ function buildApiSourcePayload(req, existingApiSource) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchApiSourcesData: fetchApiSourcesData,
|
fetchApiSourcesData: fetchApiSourcesData,
|
||||||
|
fetchApiSourcesPage: fetchApiSourcesPage,
|
||||||
fetchApiSourceById: fetchApiSourceById,
|
fetchApiSourceById: fetchApiSourceById,
|
||||||
fetchApiSourceResponse: fetchApiSourceResponse,
|
fetchApiSourceResponse: fetchApiSourceResponse,
|
||||||
buildApiSourcePayload: buildApiSourcePayload
|
buildApiSourcePayload: buildApiSourcePayload
|
||||||
|
|||||||
@@ -1,8 +1,31 @@
|
|||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
async function fetchCanvasSizesData(pool) {
|
async function fetchCanvasSizesData(pool) {
|
||||||
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
|
||||||
return { canvasSizes };
|
return { canvasSizes };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchCanvasSizeById(pool, id) {
|
async function fetchCanvasSizeById(pool, id) {
|
||||||
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
const [rows] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
@@ -28,6 +51,7 @@ function buildCanvasSizePayload(req, existingCanvasSize) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchCanvasSizesData,
|
fetchCanvasSizesData,
|
||||||
|
fetchCanvasSizesPage,
|
||||||
fetchCanvasSizeById,
|
fetchCanvasSizeById,
|
||||||
buildCanvasSizePayload
|
buildCanvasSizePayload
|
||||||
};
|
};
|
||||||
|
|||||||
+10
-3
@@ -1,7 +1,7 @@
|
|||||||
const { fetchAdminData } = require('./admin');
|
const { fetchAdminData, fetchPlaylistsPage, fetchSlidesPage, fetchTemplatesPage, fetchCanvasSizesPage, fetchScreensPage } = require('./admin');
|
||||||
const { fetchPlaylistById } = require('./playlists');
|
const { fetchPlaylistById } = require('./playlists');
|
||||||
const { fetchApiSourcesData, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
const { fetchApiSourcesData, fetchApiSourcesPage, fetchApiSourceById, fetchApiSourceResponse, buildApiSourcePayload } = require('./api-sources');
|
||||||
const { fetchRssFeedsData, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
const { fetchRssFeedsData, fetchRssFeedsPage, fetchRssFeedById, fetchRssFeedItemsByFeedId, normalizeRssFeedItem, buildRssFeedPayload, fetchRssFeedItems, replaceRssFeedItems } = require('./rss-feeds');
|
||||||
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
|
||||||
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
|
||||||
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
|
||||||
@@ -13,12 +13,19 @@ module.exports = {
|
|||||||
uniqueScreenSlug,
|
uniqueScreenSlug,
|
||||||
parseJsonSafe,
|
parseJsonSafe,
|
||||||
fetchAdminData,
|
fetchAdminData,
|
||||||
|
fetchPlaylistsPage,
|
||||||
|
fetchSlidesPage,
|
||||||
|
fetchTemplatesPage,
|
||||||
|
fetchCanvasSizesPage,
|
||||||
|
fetchScreensPage,
|
||||||
fetchPlaylistById,
|
fetchPlaylistById,
|
||||||
fetchApiSourcesData,
|
fetchApiSourcesData,
|
||||||
|
fetchApiSourcesPage,
|
||||||
fetchApiSourceById,
|
fetchApiSourceById,
|
||||||
fetchApiSourceResponse,
|
fetchApiSourceResponse,
|
||||||
buildApiSourcePayload,
|
buildApiSourcePayload,
|
||||||
fetchRssFeedsData,
|
fetchRssFeedsData,
|
||||||
|
fetchRssFeedsPage,
|
||||||
fetchRssFeedById,
|
fetchRssFeedById,
|
||||||
fetchRssFeedItemsByFeedId,
|
fetchRssFeedItemsByFeedId,
|
||||||
normalizeRssFeedItem,
|
normalizeRssFeedItem,
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
async function fetchPlaylistById(pool, id) {
|
async function fetchPlaylistById(pool, id) {
|
||||||
const [rows] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
const [rows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [id]);
|
||||||
return rows[0] || null;
|
return rows[0] || null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const https = require('https');
|
const https = require('https');
|
||||||
|
const { fetchPagedRows } = require('./utils');
|
||||||
|
|
||||||
function normalizeUpdateIntervalUnit(value) {
|
function normalizeUpdateIntervalUnit(value) {
|
||||||
const unit = String(value || '').trim().toLowerCase();
|
const unit = String(value || '').trim().toLowerCase();
|
||||||
@@ -14,6 +15,29 @@ async function fetchRssFeedsData(pool) {
|
|||||||
return { rssFeeds: rssFeeds };
|
return { rssFeeds: rssFeeds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ rssFeeds: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchRssFeedById(pool, id) {
|
async function fetchRssFeedById(pool, id) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
|
'SELECT id, name, feed_url, update_interval_value, update_interval_unit, item_limit, created_at, modified_at, created_by, modified_by FROM rss_feeds WHERE id = ?',
|
||||||
@@ -285,6 +309,7 @@ function buildRssFeedPayload(req, existingRssFeed) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
fetchRssFeedsData,
|
fetchRssFeedsData,
|
||||||
|
fetchRssFeedsPage,
|
||||||
fetchRssFeedById,
|
fetchRssFeedById,
|
||||||
fetchRssFeedItemsByFeedId,
|
fetchRssFeedItemsByFeedId,
|
||||||
normalizeRssFeedItem,
|
normalizeRssFeedItem,
|
||||||
|
|||||||
+1
-1
@@ -38,7 +38,7 @@ async function fetchScreenById(pool, id) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchScreenEditData(pool) {
|
async function fetchScreenEditData(pool) {
|
||||||
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
const [playlists] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists ORDER BY id DESC');
|
||||||
return { playlists };
|
return { playlists };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+14
-2
@@ -27,7 +27,7 @@ function sanitizeRichText(html) {
|
|||||||
|
|
||||||
async function fetchSlideById(pool, id) {
|
async function fetchSlideById(pool, id) {
|
||||||
const [slides] = await pool.query(`
|
const [slides] = await pool.query(`
|
||||||
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at,
|
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,
|
||||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
FROM slides s
|
FROM slides s
|
||||||
LEFT JOIN slide_templates st ON st.id = s.template_id
|
LEFT JOIN slide_templates st ON st.id = s.template_id
|
||||||
@@ -90,7 +90,19 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
|||||||
const existing = body[`existing_region_image_${region.id}`];
|
const existing = body[`existing_region_image_${region.id}`];
|
||||||
content[region.region_key] = {
|
content[region.region_key] = {
|
||||||
type: 'image',
|
type: 'image',
|
||||||
value: uploaded ? `/media/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
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') {
|
} else if (region.region_type === 'webpage') {
|
||||||
const submitted = body[`region_webpage_${region.id}`];
|
const submitted = body[`region_webpage_${region.id}`];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
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'];
|
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||||
|
|
||||||
function sanitizeBackgroundColor(value) {
|
function sanitizeBackgroundColor(value) {
|
||||||
@@ -154,7 +154,7 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
|
|||||||
const removeBackgroundImage = Boolean(req.body.remove_background_image);
|
const removeBackgroundImage = Boolean(req.body.remove_background_image);
|
||||||
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
|
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
|
||||||
const backgroundImagePath = backgroundImage
|
const backgroundImagePath = backgroundImage
|
||||||
? `/media/${backgroundImage.filename}`
|
? `/media/uploads/${backgroundImage.filename}`
|
||||||
: removeBackgroundImage
|
: removeBackgroundImage
|
||||||
? null
|
? null
|
||||||
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
|
||||||
|
|||||||
@@ -22,6 +22,183 @@ function readFormArray(body, key) {
|
|||||||
return [body[key]];
|
return [body[key]];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizePageNumber(value) {
|
||||||
|
const pageNumber = Math.floor(Number(value) || 1);
|
||||||
|
return Math.max(1, pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeLikeValue(value) {
|
||||||
|
return String(value || '').replace(/[\\%_]/g, '\\$&');
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeSortDirection(value) {
|
||||||
|
return String(value || '').trim().toLowerCase() === 'desc' ? 'desc' : 'asc';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSortOrderClause(sortColumns, sortKey, sortDirection) {
|
||||||
|
const normalizedSortKey = String(sortKey || '').trim();
|
||||||
|
const normalizedSortDirection = normalizeSortDirection(sortDirection);
|
||||||
|
const sortColumnMap = sortColumns && typeof sortColumns === 'object' ? sortColumns : {};
|
||||||
|
const sortExpression = normalizedSortKey ? sortColumnMap[normalizedSortKey] : null;
|
||||||
|
|
||||||
|
if (!sortExpression) {
|
||||||
|
return {
|
||||||
|
clause: '',
|
||||||
|
sortKey: normalizedSortKey,
|
||||||
|
sortDirection: normalizedSortDirection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const expressions = Array.isArray(sortExpression) ? sortExpression : [sortExpression];
|
||||||
|
const orderBySql = expressions.map(function (expression) {
|
||||||
|
return `${expression} ${normalizedSortDirection.toUpperCase()}`;
|
||||||
|
}).join(', ');
|
||||||
|
|
||||||
|
return {
|
||||||
|
clause: ` ORDER BY ${orderBySql}`,
|
||||||
|
sortKey: normalizedSortKey,
|
||||||
|
sortDirection: normalizedSortDirection
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function findTopLevelOrderByIndex(sql) {
|
||||||
|
const text = String(sql || '');
|
||||||
|
let depth = 0;
|
||||||
|
let inSingleQuote = false;
|
||||||
|
let inDoubleQuote = false;
|
||||||
|
let inBacktick = false;
|
||||||
|
let lastOrderByIndex = -1;
|
||||||
|
|
||||||
|
for (let index = 0; index < text.length; index += 1) {
|
||||||
|
const character = text[index];
|
||||||
|
const previousCharacter = index > 0 ? text[index - 1] : '';
|
||||||
|
|
||||||
|
if (inSingleQuote) {
|
||||||
|
if (character === '\'' && previousCharacter !== '\\') {
|
||||||
|
inSingleQuote = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inDoubleQuote) {
|
||||||
|
if (character === '"' && previousCharacter !== '\\') {
|
||||||
|
inDoubleQuote = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inBacktick) {
|
||||||
|
if (character === '`') {
|
||||||
|
inBacktick = false;
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '\'') {
|
||||||
|
inSingleQuote = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '"') {
|
||||||
|
inDoubleQuote = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '`') {
|
||||||
|
inBacktick = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === '(') {
|
||||||
|
depth += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (character === ')' && depth > 0) {
|
||||||
|
depth -= 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (depth === 0 && /[oO]/.test(character)) {
|
||||||
|
const remaining = text.slice(index);
|
||||||
|
if (/^order\s+by\b/i.test(remaining)) {
|
||||||
|
lastOrderByIndex = index;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return lastOrderByIndex;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSearchFilter(searchColumns, searchTerm) {
|
||||||
|
const columns = Array.isArray(searchColumns) ? searchColumns.map(function (column) {
|
||||||
|
return String(column || '').trim();
|
||||||
|
}).filter(Boolean) : [];
|
||||||
|
const normalizedSearchTerm = String(searchTerm || '').trim().toLowerCase();
|
||||||
|
|
||||||
|
if (!columns.length || !normalizedSearchTerm) {
|
||||||
|
return {
|
||||||
|
clause: '',
|
||||||
|
params: []
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const likeValue = `%${escapeLikeValue(normalizedSearchTerm)}%`;
|
||||||
|
return {
|
||||||
|
clause: ` WHERE (${columns.map(function (column) {
|
||||||
|
return `LOWER(COALESCE(CAST(${column} AS CHAR), '')) LIKE ? ESCAPE '\\\\'`;
|
||||||
|
}).join(' OR ')})`,
|
||||||
|
params: columns.map(function () {
|
||||||
|
return likeValue;
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchPagedRows(pool, options) {
|
||||||
|
const selectSql = String(options && options.selectSql || '').trim();
|
||||||
|
const countSql = String(options && options.countSql || '').trim();
|
||||||
|
const params = Array.isArray(options && options.params) ? options.params : [];
|
||||||
|
const searchFilter = buildSearchFilter(options && options.searchColumns, options && options.searchTerm);
|
||||||
|
const sortOrder = buildSortOrderClause(options && options.sortColumns, options && options.sortKey, options && options.sortDirection);
|
||||||
|
const pageSize = Math.max(1, Number(options && options.pageSize) || 10);
|
||||||
|
const currentPage = normalizePageNumber(options && options.page);
|
||||||
|
|
||||||
|
if (!selectSql || !countSql) {
|
||||||
|
throw new Error('fetchPagedRows requires selectSql and countSql.');
|
||||||
|
}
|
||||||
|
const orderByIndex = findTopLevelOrderByIndex(selectSql);
|
||||||
|
let baseSelectSql = selectSql;
|
||||||
|
let orderBySql = '';
|
||||||
|
|
||||||
|
if (orderByIndex >= 0) {
|
||||||
|
baseSelectSql = selectSql.slice(0, orderByIndex).trim();
|
||||||
|
orderBySql = selectSql.slice(orderByIndex).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
const filteredSelectSql = `${baseSelectSql}${searchFilter.clause}`;
|
||||||
|
const countQuery = searchFilter.clause
|
||||||
|
? `SELECT COUNT(*) AS count FROM (${filteredSelectSql}) AS filtered_rows`
|
||||||
|
: countSql;
|
||||||
|
const countParams = searchFilter.clause ? params.concat(searchFilter.params) : params;
|
||||||
|
const [countRows] = await pool.query(countQuery, countParams);
|
||||||
|
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||||
|
const offset = (safeCurrentPage - 1) * pageSize;
|
||||||
|
|
||||||
|
const 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 || [],
|
||||||
|
totalItems: totalItems,
|
||||||
|
totalPages: totalPages,
|
||||||
|
currentPage: safeCurrentPage,
|
||||||
|
pageSize: pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
|
async function fetchDuplicateName(pool, tableName, name, excludeId, columnName) {
|
||||||
const normalizedName = String(name || '').trim();
|
const normalizedName = String(name || '').trim();
|
||||||
if (!normalizedName) {
|
if (!normalizedName) {
|
||||||
@@ -44,5 +221,11 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
parseJsonSafe,
|
parseJsonSafe,
|
||||||
readFormArray,
|
readFormArray,
|
||||||
|
normalizePageNumber,
|
||||||
|
normalizeSortDirection,
|
||||||
|
buildSortOrderClause,
|
||||||
|
findTopLevelOrderByIndex,
|
||||||
|
buildSearchFilter,
|
||||||
|
fetchPagedRows,
|
||||||
fetchDuplicateName
|
fetchDuplicateName
|
||||||
};
|
};
|
||||||
|
|||||||
+6
-3
@@ -23,7 +23,7 @@ async function pruneStaleOnboardingDevices(pool) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function ensureSchema(pool) {
|
async function ensureSchema(pool, options) {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
CREATE TABLE IF NOT EXISTS canvas_sizes (
|
||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
@@ -43,6 +43,7 @@ async function ensureSchema(pool) {
|
|||||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||||
name VARCHAR(255) NOT NULL,
|
name VARCHAR(255) NOT NULL,
|
||||||
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
fade_between_slides TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
|
skip_unavailable_rtmp TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
@@ -102,6 +103,7 @@ async function ensureSchema(pool) {
|
|||||||
content_json JSON NULL,
|
content_json JSON NULL,
|
||||||
media_path VARCHAR(512) NULL,
|
media_path VARCHAR(512) NULL,
|
||||||
media_type VARCHAR(100) NULL,
|
media_type VARCHAR(100) NULL,
|
||||||
|
thumbnail_path VARCHAR(512) NULL,
|
||||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||||
created_by INT NULL,
|
created_by INT NULL,
|
||||||
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
modified_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||||
@@ -115,7 +117,8 @@ async function ensureSchema(pool) {
|
|||||||
playlist_id INT NOT NULL,
|
playlist_id INT NOT NULL,
|
||||||
slide_id INT NOT NULL,
|
slide_id INT NOT NULL,
|
||||||
position INT NOT NULL DEFAULT 0,
|
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_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||||
schedule_start_datetime DATETIME NULL,
|
schedule_start_datetime DATETIME NULL,
|
||||||
schedule_end_datetime DATETIME NULL,
|
schedule_end_datetime DATETIME NULL,
|
||||||
@@ -341,7 +344,7 @@ async function ensureSchema(pool) {
|
|||||||
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
[DEFAULT_ROLE.key, DEFAULT_ROLE.name, DEFAULT_ROLE.description || null, null, null]
|
||||||
);
|
);
|
||||||
|
|
||||||
await migrations.runMigrations(pool);
|
await migrations.runMigrations(pool, options || {});
|
||||||
|
|
||||||
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
if (!userCountRows.length || Number(userCountRows[0].user_count) === 0) {
|
||||||
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
const [roleRows] = await pool.query('SELECT id FROM roles WHERE role_key = ? LIMIT 1', [DEFAULT_ROLE.key]);
|
||||||
|
|||||||
+275
-15
@@ -1,3 +1,5 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
const { version: appVersion } = require('../../package.json');
|
const { version: appVersion } = require('../../package.json');
|
||||||
|
|
||||||
function parseVersion(value) {
|
function parseVersion(value) {
|
||||||
@@ -260,6 +262,27 @@ function replaceUploadPrefixInValue(value) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function replaceLegacyMediaUploadPathInValue(value) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
if (!value.startsWith('/media/') || value.startsWith('/media/uploads/')) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
return '/media/uploads/' + path.basename(value);
|
||||||
|
}
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return value.map(function (item) {
|
||||||
|
return replaceLegacyMediaUploadPathInValue(item);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (value && typeof value === 'object') {
|
||||||
|
return Object.keys(value).reduce(function (result, key) {
|
||||||
|
result[key] = replaceLegacyMediaUploadPathInValue(value[key]);
|
||||||
|
return result;
|
||||||
|
}, {});
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
function parseJsonValue(value) {
|
function parseJsonValue(value) {
|
||||||
if (value === null || value === undefined || value === '') {
|
if (value === null || value === undefined || value === '') {
|
||||||
return null;
|
return null;
|
||||||
@@ -327,6 +350,187 @@ async function backfillLegacyMediaPaths(pool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function moveFileIfMissing(sourcePath, targetPath) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(targetPath, fs.constants.F_OK);
|
||||||
|
return false;
|
||||||
|
} catch (_error) {
|
||||||
|
// target does not exist
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.access(sourcePath, fs.constants.F_OK);
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
await fs.promises.mkdir(path.dirname(targetPath), { recursive: true });
|
||||||
|
|
||||||
|
try {
|
||||||
|
await fs.promises.rename(sourcePath, targetPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (error && error.code === 'EXDEV') {
|
||||||
|
await fs.promises.copyFile(sourcePath, targetPath);
|
||||||
|
await fs.promises.unlink(sourcePath);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillLegacyMediaUploadsToSubfolder(pool, mediaDir) {
|
||||||
|
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||||
|
if (!normalizedMediaDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||||
|
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
const [slides] = await pool.query(`
|
||||||
|
SELECT id, media_path, content_json
|
||||||
|
FROM slides
|
||||||
|
WHERE media_path LIKE '/media/%'
|
||||||
|
OR content_json LIKE '%/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const slide of slides || []) {
|
||||||
|
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||||
|
let contentJson = slide.content_json;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||||
|
const fileName = path.basename(mediaPath);
|
||||||
|
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||||
|
mediaPath = '/media/uploads/' + fileName;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = parseJsonValue(contentJson);
|
||||||
|
if (parsedContent && typeof parsedContent === 'object') {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||||
|
contentJson = JSON.stringify(updatedContent);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (updatedContent !== parsedContent) {
|
||||||
|
contentJson = updatedContent;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templates] = await pool.query(`
|
||||||
|
SELECT id, background_image_path
|
||||||
|
FROM slide_templates
|
||||||
|
WHERE background_image_path LIKE '/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const template of templates || []) {
|
||||||
|
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||||
|
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(backgroundImagePath);
|
||||||
|
await moveFileIfMissing(path.join(normalizedMediaDir, fileName), path.join(uploadsDir, fileName));
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||||
|
['/media/uploads/' + fileName, template.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function backfillLooseMediaFilesToUploadsSubfolder(pool, mediaDir) {
|
||||||
|
const normalizedMediaDir = String(mediaDir || '').trim() ? path.resolve(String(mediaDir).trim()) : null;
|
||||||
|
if (!normalizedMediaDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const uploadsDir = path.join(normalizedMediaDir, 'uploads');
|
||||||
|
await fs.promises.mkdir(uploadsDir, { recursive: true });
|
||||||
|
|
||||||
|
const directoryEntries = await fs.promises.readdir(normalizedMediaDir, { withFileTypes: true });
|
||||||
|
for (const entry of directoryEntries || []) {
|
||||||
|
if (!entry || !entry.isFile()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = String(entry.name || '').trim();
|
||||||
|
if (!fileName) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sourcePath = path.join(normalizedMediaDir, fileName);
|
||||||
|
const targetPath = path.join(uploadsDir, fileName);
|
||||||
|
await moveFileIfMissing(sourcePath, targetPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
const [slides] = await pool.query(`
|
||||||
|
SELECT id, media_path, content_json
|
||||||
|
FROM slides
|
||||||
|
WHERE media_path LIKE '/media/%'
|
||||||
|
OR content_json LIKE '%/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const slide of slides || []) {
|
||||||
|
let mediaPath = String(slide.media_path || '').trim() || null;
|
||||||
|
let contentJson = slide.content_json;
|
||||||
|
let changed = false;
|
||||||
|
|
||||||
|
if (mediaPath && mediaPath.startsWith('/media/') && !mediaPath.startsWith('/media/uploads/')) {
|
||||||
|
mediaPath = '/media/uploads/' + path.basename(mediaPath);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedContent = parseJsonValue(contentJson);
|
||||||
|
if (parsedContent && typeof parsedContent === 'object') {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (JSON.stringify(updatedContent) !== JSON.stringify(parsedContent)) {
|
||||||
|
contentJson = JSON.stringify(updatedContent);
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
} else if (typeof parsedContent === 'string' && parsedContent.indexOf('/media/') !== -1) {
|
||||||
|
const updatedContent = replaceLegacyMediaUploadPathInValue(parsedContent);
|
||||||
|
if (updatedContent !== parsedContent) {
|
||||||
|
contentJson = updatedContent;
|
||||||
|
changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (changed) {
|
||||||
|
await pool.query('UPDATE slides SET media_path = ?, content_json = ? WHERE id = ?', [mediaPath, contentJson, slide.id]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [templates] = await pool.query(`
|
||||||
|
SELECT id, background_image_path
|
||||||
|
FROM slide_templates
|
||||||
|
WHERE background_image_path LIKE '/media/%'
|
||||||
|
`);
|
||||||
|
|
||||||
|
for (const template of templates || []) {
|
||||||
|
const backgroundImagePath = String(template.background_image_path || '').trim();
|
||||||
|
if (!backgroundImagePath.startsWith('/media/') || backgroundImagePath.startsWith('/media/uploads/')) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const fileName = path.basename(backgroundImagePath);
|
||||||
|
await pool.query(
|
||||||
|
'UPDATE slide_templates SET background_image_path = ? WHERE id = ?',
|
||||||
|
['/media/uploads/' + fileName, template.id]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function ensureMigrationTable(pool) {
|
async function ensureMigrationTable(pool) {
|
||||||
await pool.query(`
|
await pool.query(`
|
||||||
CREATE TABLE IF NOT EXISTS schema_migrations (
|
CREATE TABLE IF NOT EXISTS schema_migrations (
|
||||||
@@ -401,19 +605,20 @@ const migrations = [
|
|||||||
{
|
{
|
||||||
key: 'schema-columns-current',
|
key: 'schema-columns-current',
|
||||||
version: appVersion,
|
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,
|
order: 20,
|
||||||
up: async function (pool) {
|
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 addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'canvas_sizes');
|
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', '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 addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'playlists');
|
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', 'canvas_size_id', 'INT NULL');
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
||||||
@@ -421,23 +626,24 @@ const migrations = [
|
|||||||
await addAuditColumns(pool, 'slide_templates');
|
await addAuditColumns(pool, 'slide_templates');
|
||||||
await backfillLegacySlideTemplateCanvasSize(pool);
|
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', 'font_family', 'VARCHAR(100) NULL');
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) 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 addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'slide_template_regions');
|
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', 'body', 'TEXT NULL');
|
||||||
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
||||||
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
||||||
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
|
await addColumnIfMissing(pool, 'slides', 'media_path', 'VARCHAR(512) NULL');
|
||||||
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
|
await addColumnIfMissing(pool, 'slides', 'media_type', 'VARCHAR(100) NULL');
|
||||||
|
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||||
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'slides');
|
await addAuditColumns(pool, 'slides');
|
||||||
|
|
||||||
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
|
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
|
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_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
||||||
@@ -447,12 +653,12 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'playlist_slides');
|
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', 'playlist_id', 'INT NULL');
|
||||||
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'screens');
|
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', 'name', 'VARCHAR(255) NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) 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');
|
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
@@ -461,14 +667,14 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'rss_feeds');
|
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', 'rss_feed_id', 'INT NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feed_items', 'position', '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', 'item_json', 'MEDIUMTEXT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await backfillLegacyRssFeedItemJson(pool);
|
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', 'name', 'VARCHAR(255) NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) 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');
|
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
@@ -481,7 +687,7 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'api_sources');
|
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', 'client_name', 'VARCHAR(255) NULL');
|
||||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT 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');
|
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
@@ -517,6 +723,42 @@ const migrations = [
|
|||||||
await addAuditColumns(pool, 'auth_sessions');
|
await addAuditColumns(pool, 'auth_sessions');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'playlist-skip-unavailable-rtmp',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Add the playlist flag that skips RTMP slides when streams are unavailable.',
|
||||||
|
order: 21,
|
||||||
|
up: async function (pool) {
|
||||||
|
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'slides-thumbnail-path-column',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Backfill the slides.thumbnail_path column for databases that already recorded the broader schema migration.',
|
||||||
|
order: 22,
|
||||||
|
up: async function (pool) {
|
||||||
|
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',
|
key: 'media-path-prefix-rename',
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
@@ -526,6 +768,24 @@ const migrations = [
|
|||||||
await backfillLegacyMediaPaths(pool);
|
await backfillLegacyMediaPaths(pool);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'media-upload-subfolder-move',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Move existing upload files and references from the media root into media/uploads.',
|
||||||
|
order: 26,
|
||||||
|
up: async function (pool, options) {
|
||||||
|
await backfillLegacyMediaUploadsToSubfolder(pool, options && options.mediaDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'media-upload-subfolder-rescue',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Rescan loose media files and promote them into media/uploads.',
|
||||||
|
order: 27,
|
||||||
|
up: async function (pool, options) {
|
||||||
|
await backfillLooseMediaFilesToUploadsSubfolder(pool, options && options.mediaDir);
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'background-tasks-table',
|
key: 'background-tasks-table',
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
@@ -556,7 +816,7 @@ const migrations = [
|
|||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
async function runMigrations(pool) {
|
async function runMigrations(pool, options) {
|
||||||
const appliedRows = await getAppliedMigrationRows(pool);
|
const appliedRows = await getAppliedMigrationRows(pool);
|
||||||
const appliedKeys = new Set((appliedRows || []).map(function (row) {
|
const appliedKeys = new Set((appliedRows || []).map(function (row) {
|
||||||
return String(row.migration_key || '').trim();
|
return String(row.migration_key || '').trim();
|
||||||
@@ -576,7 +836,7 @@ async function runMigrations(pool) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const migration of pendingMigrations) {
|
for (const migration of pendingMigrations) {
|
||||||
await migration.up(pool);
|
await migration.up(pool, options || {});
|
||||||
await recordMigration(pool, migration);
|
await recordMigration(pool, migration);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ async function start() {
|
|||||||
|
|
||||||
async function syncDatabaseState() {
|
async function syncDatabaseState() {
|
||||||
try {
|
try {
|
||||||
await common.ensureSchema(pool);
|
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||||
|
|
||||||
if (playerRuntime.snapshotAllConnections().length > 0) {
|
if (playerRuntime.snapshotAllConnections().length > 0) {
|
||||||
await pruneStaleOnboardingDevices(pool);
|
await pruneStaleOnboardingDevices(pool);
|
||||||
|
|||||||
@@ -6,7 +6,13 @@ const { spawn } = require('child_process');
|
|||||||
function createRtmpStreamService(options) {
|
function createRtmpStreamService(options) {
|
||||||
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
const mediaDir = options && options.mediaDir ? options.mediaDir : null;
|
||||||
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
|
const ffmpegPath = options && options.ffmpegPath ? options.ffmpegPath : 'ffmpeg';
|
||||||
|
const ffprobePath = options && options.ffprobePath ? options.ffprobePath : 'ffprobe';
|
||||||
|
const probeIntervalMs = options && options.probeIntervalMs ? Number(options.probeIntervalMs) : 2000;
|
||||||
const sessions = new Map();
|
const sessions = new Map();
|
||||||
|
const watchedSources = new Set();
|
||||||
|
const sourceStatusCache = new Map();
|
||||||
|
const sourceProbePromises = new Map();
|
||||||
|
let probeTimer = null;
|
||||||
|
|
||||||
if (!mediaDir) {
|
if (!mediaDir) {
|
||||||
throw new Error('mediaDir is required');
|
throw new Error('mediaDir is required');
|
||||||
@@ -48,6 +54,207 @@ function createRtmpStreamService(options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function runCommand(command, args, timeoutMs) {
|
||||||
|
return new Promise(function (resolve) {
|
||||||
|
var child = spawn(command, args, {
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe']
|
||||||
|
});
|
||||||
|
var stdout = '';
|
||||||
|
var stderr = '';
|
||||||
|
var finished = false;
|
||||||
|
var timer = null;
|
||||||
|
|
||||||
|
function done(result) {
|
||||||
|
if (finished) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
finished = true;
|
||||||
|
if (timer) {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = null;
|
||||||
|
}
|
||||||
|
resolve(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
child.stdout.on('data', function (chunk) {
|
||||||
|
stdout += String(chunk || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
child.stderr.on('data', function (chunk) {
|
||||||
|
stderr += String(chunk || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('error', function (error) {
|
||||||
|
done({ ok: false, error: error, stdout: stdout, stderr: stderr, timedOut: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
child.on('exit', function (code, signal) {
|
||||||
|
done({ ok: code === 0, code: code, signal: signal, stdout: stdout, stderr: stderr, timedOut: false });
|
||||||
|
});
|
||||||
|
|
||||||
|
timer = setTimeout(function () {
|
||||||
|
try {
|
||||||
|
child.kill('SIGKILL');
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore timeout cleanup errors
|
||||||
|
}
|
||||||
|
done({ ok: false, code: null, signal: 'SIGKILL', stdout: stdout, stderr: stderr, timedOut: true });
|
||||||
|
}, Math.max(1000, Number(timeoutMs || 0) || 4000));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function probeSourceUrl(sourceUrl) {
|
||||||
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
|
const result = await runCommand(ffprobePath, [
|
||||||
|
'-hide_banner',
|
||||||
|
'-loglevel', 'error',
|
||||||
|
'-rw_timeout', '3000000',
|
||||||
|
'-show_streams',
|
||||||
|
'-of', 'json',
|
||||||
|
normalizedSource
|
||||||
|
], 4000);
|
||||||
|
|
||||||
|
if (!result.ok) {
|
||||||
|
return {
|
||||||
|
live: false,
|
||||||
|
timedOut: Boolean(result.timedOut),
|
||||||
|
stderr: String(result.stderr || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(String(result.stdout || '{}'));
|
||||||
|
const streams = Array.isArray(parsed && parsed.streams) ? parsed.streams : [];
|
||||||
|
return {
|
||||||
|
live: streams.length > 0,
|
||||||
|
timedOut: false,
|
||||||
|
stderr: String(result.stderr || '').trim()
|
||||||
|
};
|
||||||
|
} catch (_error) {
|
||||||
|
return {
|
||||||
|
live: false,
|
||||||
|
timedOut: false,
|
||||||
|
stderr: String(result.stderr || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSourceStatus(sourceUrl) {
|
||||||
|
return sourceStatusCache.get(String(sourceUrl || '').trim()) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setSourceStatus(sourceUrl, nextStatus) {
|
||||||
|
const normalizedSource = String(sourceUrl || '').trim();
|
||||||
|
if (!normalizedSource) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const status = Object.assign({
|
||||||
|
live: false,
|
||||||
|
probing: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
timedOut: false,
|
||||||
|
stderr: ''
|
||||||
|
}, nextStatus || {});
|
||||||
|
status.live = Boolean(status.live);
|
||||||
|
status.probing = Boolean(status.probing);
|
||||||
|
status.checkedAt = Number(status.checkedAt || Date.now());
|
||||||
|
sourceStatusCache.set(normalizedSource, status);
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerSourceWatch(sourceUrl) {
|
||||||
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
|
watchedSources.add(normalizedSource);
|
||||||
|
return normalizedSource;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshSourceStatus(sourceUrl) {
|
||||||
|
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||||
|
const existingPromise = sourceProbePromises.get(normalizedSource) || null;
|
||||||
|
if (existingPromise) {
|
||||||
|
return existingPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSourceStatus(normalizedSource, Object.assign({}, getSourceStatus(normalizedSource) || {}, {
|
||||||
|
probing: true
|
||||||
|
}));
|
||||||
|
|
||||||
|
const probePromise = probeSourceUrl(normalizedSource).then(function (probe) {
|
||||||
|
return setSourceStatus(normalizedSource, {
|
||||||
|
live: Boolean(probe.live),
|
||||||
|
probing: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
timedOut: Boolean(probe.timedOut),
|
||||||
|
stderr: String(probe.stderr || '')
|
||||||
|
});
|
||||||
|
}).catch(function (error) {
|
||||||
|
return setSourceStatus(normalizedSource, {
|
||||||
|
live: false,
|
||||||
|
probing: false,
|
||||||
|
checkedAt: Date.now(),
|
||||||
|
timedOut: false,
|
||||||
|
stderr: String(error && error.message ? error.message : '')
|
||||||
|
});
|
||||||
|
}).finally(function () {
|
||||||
|
sourceProbePromises.delete(normalizedSource);
|
||||||
|
});
|
||||||
|
|
||||||
|
sourceProbePromises.set(normalizedSource, probePromise);
|
||||||
|
return probePromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSourceRefresh(sourceUrl) {
|
||||||
|
const normalizedSource = registerSourceWatch(sourceUrl);
|
||||||
|
const currentStatus = getSourceStatus(normalizedSource);
|
||||||
|
if (currentStatus && currentStatus.probing) {
|
||||||
|
return sourceProbePromises.get(normalizedSource) || Promise.resolve(currentStatus);
|
||||||
|
}
|
||||||
|
return refreshSourceStatus(normalizedSource);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSourceStatusFresh(status) {
|
||||||
|
if (!status || !status.checkedAt) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return Date.now() - Number(status.checkedAt || 0) < probeIntervalMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sweepWatchedSources() {
|
||||||
|
watchedSources.forEach(function (sourceUrl) {
|
||||||
|
const status = getSourceStatus(sourceUrl);
|
||||||
|
if (!status || !isSourceStatusFresh(status) || status.live === false) {
|
||||||
|
scheduleSourceRefresh(sourceUrl);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (probeIntervalMs > 0) {
|
||||||
|
probeTimer = setInterval(function () {
|
||||||
|
sweepWatchedSources();
|
||||||
|
}, probeIntervalMs);
|
||||||
|
if (probeTimer && typeof probeTimer.unref === 'function') {
|
||||||
|
probeTimer.unref();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function isSessionLive(session) {
|
||||||
|
if (!session || !session.process) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (session.exitCode !== undefined && session.exitCode !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if (session.exitSignal !== undefined && session.exitSignal !== null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const stat = await fs.promises.stat(session.manifestPath);
|
||||||
|
return stat.isFile() && stat.size > 0;
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function buildPlaylistUrl(key) {
|
function buildPlaylistUrl(key) {
|
||||||
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
|
return '/api/rtmp/streams/' + encodeURIComponent(key) + '/index.m3u8';
|
||||||
}
|
}
|
||||||
@@ -56,14 +263,30 @@ function createRtmpStreamService(options) {
|
|||||||
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
const normalizedDisableAudio = Boolean(disableAudio);
|
const normalizedDisableAudio = Boolean(disableAudio);
|
||||||
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
|
const key = getSessionKey(normalizedSource, normalizedDisableAudio);
|
||||||
|
|
||||||
if (sessions.has(key)) {
|
|
||||||
return sessions.get(key);
|
|
||||||
}
|
|
||||||
|
|
||||||
const directory = path.join(cacheRoot, key);
|
const directory = path.join(cacheRoot, key);
|
||||||
const manifestPath = path.join(directory, 'index.m3u8');
|
const manifestPath = path.join(directory, 'index.m3u8');
|
||||||
|
|
||||||
|
const existingSession = sessions.get(key) || null;
|
||||||
|
if (existingSession && existingSession.process && existingSession.exitCode === null && existingSession.exitSignal === null) {
|
||||||
|
return existingSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (existingSession) {
|
||||||
|
sessions.delete(key);
|
||||||
|
try {
|
||||||
|
if (existingSession.process && typeof existingSession.process.kill === 'function') {
|
||||||
|
existingSession.process.kill('SIGKILL');
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
await fs.promises.rm(directory, { recursive: true, force: true });
|
||||||
|
} catch (_error2) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await ensureDirectory(directory);
|
await ensureDirectory(directory);
|
||||||
|
|
||||||
const args = [
|
const args = [
|
||||||
@@ -117,6 +340,8 @@ function createRtmpStreamService(options) {
|
|||||||
manifestPath: manifestPath,
|
manifestPath: manifestPath,
|
||||||
playlistUrl: buildPlaylistUrl(key),
|
playlistUrl: buildPlaylistUrl(key),
|
||||||
process: child,
|
process: child,
|
||||||
|
exitCode: null,
|
||||||
|
exitSignal: null,
|
||||||
ready: waitForFile(manifestPath, 5000)
|
ready: waitForFile(manifestPath, 5000)
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -132,6 +357,51 @@ function createRtmpStreamService(options) {
|
|||||||
return session.playlistUrl + '?t=' + Date.now();
|
return session.playlistUrl + '?t=' + Date.now();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function getSessionStatus(sourceUrl, disableAudio) {
|
||||||
|
const normalizedSource = normalizeSourceUrl(sourceUrl);
|
||||||
|
const normalizedDisableAudio = Boolean(disableAudio);
|
||||||
|
registerSourceWatch(normalizedSource);
|
||||||
|
|
||||||
|
let sourceStatus = getSourceStatus(normalizedSource);
|
||||||
|
if (!sourceStatus) {
|
||||||
|
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
sourceStatus = getSourceStatus(normalizedSource);
|
||||||
|
} else if (!isSourceStatusFresh(sourceStatus) && !sourceStatus.probing) {
|
||||||
|
scheduleSourceRefresh(normalizedSource).catch(function (_error) {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sourceStatus || !sourceStatus.live) {
|
||||||
|
return {
|
||||||
|
session: null,
|
||||||
|
ready: false,
|
||||||
|
live: false,
|
||||||
|
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||||
|
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||||
|
timedOut: Boolean(sourceStatus && sourceStatus.timedOut),
|
||||||
|
stderr: String(sourceStatus && sourceStatus.stderr || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const session = await ensureSession(normalizedSource, normalizedDisableAudio);
|
||||||
|
const ready = await session.ready.catch(function () {
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
const live = ready && Boolean(session && session.process && session.exitCode === null && session.exitSignal === null);
|
||||||
|
return {
|
||||||
|
session: session,
|
||||||
|
ready: ready,
|
||||||
|
live: live,
|
||||||
|
probing: Boolean(sourceStatus && sourceStatus.probing),
|
||||||
|
checkedAt: sourceStatus ? sourceStatus.checkedAt : null,
|
||||||
|
timedOut: false,
|
||||||
|
stderr: ''
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
function getSessionByKey(key) {
|
function getSessionByKey(key) {
|
||||||
return sessions.get(String(key || '').trim()) || null;
|
return sessions.get(String(key || '').trim()) || null;
|
||||||
}
|
}
|
||||||
@@ -141,9 +411,13 @@ function createRtmpStreamService(options) {
|
|||||||
if (!session) {
|
if (!session) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
await session.ready.catch(function () {
|
if (!session.process || session.exitCode !== null || session.exitSignal !== null) {
|
||||||
return false;
|
return null;
|
||||||
});
|
}
|
||||||
|
const live = await isSessionLive(session);
|
||||||
|
if (!live) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return session.manifestPath;
|
return session.manifestPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -163,6 +437,11 @@ function createRtmpStreamService(options) {
|
|||||||
ensureSession: ensureSession,
|
ensureSession: ensureSession,
|
||||||
getPlaylistUrl: getPlaylistUrl,
|
getPlaylistUrl: getPlaylistUrl,
|
||||||
getSessionByKey: getSessionByKey,
|
getSessionByKey: getSessionByKey,
|
||||||
|
isSessionLive: isSessionLive,
|
||||||
|
getSessionStatus: getSessionStatus,
|
||||||
|
refreshSourceStatus: refreshSourceStatus,
|
||||||
|
scheduleSourceRefresh: scheduleSourceRefresh,
|
||||||
|
getSourceStatus: getSourceStatus,
|
||||||
getManifestFilePath: getManifestFilePath,
|
getManifestFilePath: getManifestFilePath,
|
||||||
getSegmentFilePath: getSegmentFilePath
|
getSegmentFilePath: getSegmentFilePath
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,16 +6,19 @@
|
|||||||
let currentPlaylistSignature = '';
|
let currentPlaylistSignature = '';
|
||||||
let currentPlaylistEtag = '';
|
let currentPlaylistEtag = '';
|
||||||
let currentPlaylistFadeBetweenSlides = false;
|
let currentPlaylistFadeBetweenSlides = false;
|
||||||
|
let currentPlaylistSkipUnavailableRtmp = false;
|
||||||
let pendingPlaylistUpdate = null;
|
let pendingPlaylistUpdate = null;
|
||||||
let activeSlidesCacheKey = '';
|
let activeSlidesCacheKey = '';
|
||||||
let activeSlidesCacheValue = [];
|
let activeSlidesCacheValue = [];
|
||||||
let slideMarkupCache = Object.create(null);
|
let slideMarkupCache = Object.create(null);
|
||||||
let templateLayoutCache = Object.create(null);
|
let templateLayoutCache = Object.create(null);
|
||||||
let templateRenderPlanCache = Object.create(null);
|
let templateRenderPlanCache = Object.create(null);
|
||||||
|
let videoRegionRenderVersion = 0;
|
||||||
let renderCacheViewportKey = '';
|
let renderCacheViewportKey = '';
|
||||||
let index = 0;
|
let index = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
let slideTransitionTimer = null;
|
let slideTransitionTimer = null;
|
||||||
|
let slideRenderRequestId = 0;
|
||||||
let preloadContainer = null;
|
let preloadContainer = null;
|
||||||
let preloadSignature = '';
|
let preloadSignature = '';
|
||||||
let commandSocket = null;
|
let commandSocket = null;
|
||||||
@@ -154,16 +157,18 @@
|
|||||||
}
|
}
|
||||||
if (initialData && initialData.playlist) {
|
if (initialData && initialData.playlist) {
|
||||||
currentPlaylistFadeBetweenSlides = Boolean(initialData.playlist.fade_between_slides);
|
currentPlaylistFadeBetweenSlides = Boolean(initialData.playlist.fade_between_slides);
|
||||||
|
currentPlaylistSkipUnavailableRtmp = Boolean(initialData.playlist.skip_unavailable_rtmp);
|
||||||
}
|
}
|
||||||
savePlaylistSnapshot({
|
savePlaylistSnapshot({
|
||||||
slides: slides,
|
slides: slides,
|
||||||
signature: currentPlaylistSignature,
|
signature: currentPlaylistSignature,
|
||||||
fadeBetweenSlides: currentPlaylistFadeBetweenSlides,
|
fadeBetweenSlides: currentPlaylistFadeBetweenSlides,
|
||||||
|
skipUnavailableRtmp: currentPlaylistSkipUnavailableRtmp,
|
||||||
etag: currentPlaylistEtag
|
etag: currentPlaylistEtag
|
||||||
});
|
});
|
||||||
syncWebpagePreloads(getActiveSlidesFrom(slides), index);
|
syncWebpagePreloads(getActiveSlidesFrom(slides), index);
|
||||||
if (typeof syncRtmpPreloads === 'function') {
|
if (typeof syncRtmpWarmups === 'function') {
|
||||||
syncRtmpPreloads(getActiveSlidesFrom(slides), index);
|
syncRtmpWarmups(getActiveSlidesFrom(slides), index);
|
||||||
}
|
}
|
||||||
syncBlackoutState();
|
syncBlackoutState();
|
||||||
showCurrent();
|
showCurrent();
|
||||||
|
|||||||
+41
-5
@@ -73,11 +73,11 @@ function createPlayerPlaylistService(options) {
|
|||||||
return payloadWithoutPlaylist;
|
return payloadWithoutPlaylist;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, 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 playlist = playlistRows[0] || null;
|
||||||
const [slideRows] = await pool.query(`
|
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,
|
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,
|
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
|
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
|
FROM playlist_slides ps
|
||||||
JOIN slides sl ON sl.id = ps.slide_id
|
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 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 {
|
return {
|
||||||
id: slide.id,
|
id: slide.id,
|
||||||
title: slide.title,
|
title: slide.title,
|
||||||
body: slide.body,
|
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_mode: slide.schedule_mode,
|
||||||
schedule_start_datetime: slide.schedule_start_datetime,
|
schedule_start_datetime: slide.schedule_start_datetime,
|
||||||
schedule_end_datetime: slide.schedule_end_datetime,
|
schedule_end_datetime: slide.schedule_end_datetime,
|
||||||
@@ -125,7 +159,7 @@ function createPlayerPlaylistService(options) {
|
|||||||
kind: common.mediaKind(slide.media_path),
|
kind: common.mediaKind(slide.media_path),
|
||||||
template_id: slide.template_id,
|
template_id: slide.template_id,
|
||||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||||
content: common.parseJsonSafe(slide.content_json) || {}
|
content: content
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -180,6 +214,7 @@ function createPlayerPlaylistService(options) {
|
|||||||
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
updatePlaylistRevisionHash(hash, playlist && playlist.id);
|
||||||
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
updatePlaylistRevisionHash(hash, playlist && playlist.modified_at);
|
||||||
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
updatePlaylistRevisionHash(hash, playlist && playlist.fade_between_slides);
|
||||||
|
updatePlaylistRevisionHash(hash, playlist && playlist.skip_unavailable_rtmp);
|
||||||
|
|
||||||
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
(Array.isArray(slideRows) ? slideRows : []).forEach(function (slide) {
|
||||||
updatePlaylistRevisionHash(hash, slide.id);
|
updatePlaylistRevisionHash(hash, slide.id);
|
||||||
@@ -192,6 +227,7 @@ function createPlayerPlaylistService(options) {
|
|||||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||||
updatePlaylistRevisionHash(hash, slide.position);
|
updatePlaylistRevisionHash(hash, slide.position);
|
||||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||||
|
|||||||
@@ -364,6 +364,19 @@ body.screen-blackout #app {
|
|||||||
display: block;
|
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 {
|
.template-region.webpage iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -397,6 +410,7 @@ body.screen-blackout #app {
|
|||||||
.template-region-rtmp-placeholder {
|
.template-region-rtmp-placeholder {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
|
|
||||||
.template-region-placeholder {
|
.template-region-placeholder {
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 19 KiB |
@@ -89,6 +89,12 @@ function scheduleSlideAdvance(delayMs) {
|
|||||||
}, holdDelayMs);
|
}, holdDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the fade time to a slide's hold duration unless the slide duration already accounts for it.
|
||||||
|
function getSlideHoldDelay(delayMs, skipFadePadding) {
|
||||||
|
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||||
|
return holdDelayMs + (currentPlaylistFadeBetweenSlides && !skipFadePadding ? slideFadeDurationMs : 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel any pending fade-transition cleanup.
|
// Cancel any pending fade-transition cleanup.
|
||||||
function clearSlideTransitionTimer() {
|
function clearSlideTransitionTimer() {
|
||||||
if (slideTransitionTimer) {
|
if (slideTransitionTimer) {
|
||||||
@@ -103,11 +109,48 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
if (typeof destroyRtmpRegions === 'function') {
|
if (typeof destroyRtmpRegions === 'function') {
|
||||||
destroyRtmpRegions(app);
|
destroyRtmpRegions(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeRenderedVideoPlayback(root) {
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var videos = root.querySelectorAll('.template-region.video video');
|
||||||
|
Array.prototype.forEach.call(videos, function (video) {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
startPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener('canplay', startPlayback, { once: true });
|
||||||
|
video.addEventListener('loadedmetadata', startPlayback, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldFade) {
|
if (!shouldFade) {
|
||||||
app.innerHTML = markup;
|
app.innerHTML = markup;
|
||||||
if (typeof syncRtmpRegions === 'function') {
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
syncRtmpRegions(app);
|
syncRtmpRegions(app);
|
||||||
}
|
}
|
||||||
|
initializeRenderedVideoPlayback(app);
|
||||||
return app.firstElementChild;
|
return app.firstElementChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -137,6 +180,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
if (typeof syncRtmpRegions === 'function') {
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
syncRtmpRegions(nextShell);
|
syncRtmpRegions(nextShell);
|
||||||
}
|
}
|
||||||
|
initializeRenderedVideoPlayback(nextShell);
|
||||||
return nextShell;
|
return nextShell;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -156,6 +200,8 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
syncRtmpRegions(nextShell);
|
syncRtmpRegions(nextShell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeRenderedVideoPlayback(nextShell);
|
||||||
|
|
||||||
slideTransitionTimer = window.setTimeout(function () {
|
slideTransitionTimer = window.setTimeout(function () {
|
||||||
if (previousShell && previousShell.parentNode) {
|
if (previousShell && previousShell.parentNode) {
|
||||||
previousShell.parentNode.removeChild(previousShell);
|
previousShell.parentNode.removeChild(previousShell);
|
||||||
@@ -274,7 +320,12 @@ function handleCommandMessage(rawMessage) {
|
|||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case 'pause':
|
case 'pause':
|
||||||
setPaused(!isPaused);
|
var desiredPause = normalizeBoolean(payload.paused);
|
||||||
|
if (desiredPause !== null) {
|
||||||
|
setPaused(desiredPause);
|
||||||
|
} else {
|
||||||
|
setPaused(!isPaused);
|
||||||
|
}
|
||||||
return;
|
return;
|
||||||
case 'blackout':
|
case 'blackout':
|
||||||
var desiredBlackout = normalizeBoolean(payload.blackout);
|
var desiredBlackout = normalizeBoolean(payload.blackout);
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
// Show or hide the offline status banner.
|
// Show or hide the offline status banner.
|
||||||
function setOfflineBannerVisible(visible, message) {
|
function setOfflineBannerVisible(visible, message) {
|
||||||
|
if (window.__pulseThumbnailPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var normalizedVisible = Boolean(visible);
|
var normalizedVisible = Boolean(visible);
|
||||||
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
|
var bannerMessage = String(message || 'Offline mode: using cached playlist.').trim();
|
||||||
if (normalizedVisible) {
|
if (normalizedVisible) {
|
||||||
@@ -38,6 +41,9 @@ function setOfflineBannerVisible(visible, message) {
|
|||||||
|
|
||||||
// Update the offline banner based on connectivity or playlist availability.
|
// Update the offline banner based on connectivity or playlist availability.
|
||||||
function syncOfflineBanner() {
|
function syncOfflineBanner() {
|
||||||
|
if (window.__pulseThumbnailPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (!window.navigator.onLine) {
|
if (!window.navigator.onLine) {
|
||||||
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
|
setOfflineBannerVisible(true, 'Offline mode: using cached playlist.');
|
||||||
return;
|
return;
|
||||||
@@ -57,6 +63,9 @@ function clearRefreshRetry() {
|
|||||||
|
|
||||||
// Retry playlist refresh with a short backoff while the player is offline.
|
// Retry playlist refresh with a short backoff while the player is offline.
|
||||||
function scheduleRefreshRetry() {
|
function scheduleRefreshRetry() {
|
||||||
|
if (window.__pulseThumbnailPreview) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (refreshRetryTimer) {
|
if (refreshRetryTimer) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Render the slide at the requested index within the active set.
|
// Render the slide at the requested index within the active set.
|
||||||
function renderSlideAtIndex(sourceSlides, targetIndex) {
|
async function renderSlideAtIndex(sourceSlides, targetIndex) {
|
||||||
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
const availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||||
if (!availableSlides.length) {
|
if (!availableSlides.length) {
|
||||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||||
@@ -11,18 +11,52 @@ function renderSlideAtIndex(sourceSlides, targetIndex) {
|
|||||||
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
if (!Number.isFinite(normalizedIndex) || normalizedIndex < 0 || normalizedIndex >= availableSlides.length) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const slide = availableSlides[normalizedIndex];
|
var slideCount = availableSlides.length;
|
||||||
|
var currentIndex = normalizedIndex;
|
||||||
|
var slide = null;
|
||||||
|
var attempts = 0;
|
||||||
|
var renderRequestId = ++slideRenderRequestId;
|
||||||
|
|
||||||
|
while (attempts < slideCount) {
|
||||||
|
slide = availableSlides[currentIndex];
|
||||||
|
if (!slide) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
if (currentPlaylistSkipUnavailableRtmp && typeof probeRtmpSlideAvailability === 'function') {
|
||||||
|
var isAvailable = await probeRtmpSlideAvailability(slide);
|
||||||
|
if (!isAvailable) {
|
||||||
|
if (renderRequestId !== slideRenderRequestId) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
currentIndex = (currentIndex + 1) % slideCount;
|
||||||
|
attempts += 1;
|
||||||
|
slide = null;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (renderRequestId !== slideRenderRequestId) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
if (!slide) {
|
if (!slide) {
|
||||||
|
if (currentPlaylistSkipUnavailableRtmp) {
|
||||||
|
renderEmpty(slides.length ? 'No RTMP slides are currently available.' : 'No slides assigned to this screen yet.');
|
||||||
|
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||||
|
scheduleRefreshRetry();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
index = normalizedIndex;
|
index = currentIndex;
|
||||||
var markup = buildSlideMarkup(slide);
|
var markup = buildSlideMarkup(slide);
|
||||||
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
renderSlideMarkup(markup, currentPlaylistFadeBetweenSlides);
|
||||||
sendCommandState(slide);
|
sendCommandState(slide);
|
||||||
if (!isPaused) {
|
if (!isPaused) {
|
||||||
scheduleSlideAdvance(Math.max(1, Number(slide.duration_seconds || 10)) * 1000);
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(slide.duration_seconds || 10)) * 1000));
|
||||||
}
|
}
|
||||||
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
lastRenderedViewKey = getCurrentRenderKey(availableSlides);
|
||||||
return true;
|
return true;
|
||||||
@@ -36,6 +70,7 @@ function applyPendingPlaylistUpdate() {
|
|||||||
slides = pendingPlaylistUpdate.slides;
|
slides = pendingPlaylistUpdate.slides;
|
||||||
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
currentPlaylistSignature = pendingPlaylistUpdate.signature;
|
||||||
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
currentPlaylistFadeBetweenSlides = pendingPlaylistUpdate.fadeBetweenSlides;
|
||||||
|
currentPlaylistSkipUnavailableRtmp = Boolean(pendingPlaylistUpdate.skipUnavailableRtmp);
|
||||||
pendingPlaylistUpdate = null;
|
pendingPlaylistUpdate = null;
|
||||||
clearActiveSlidesCache();
|
clearActiveSlidesCache();
|
||||||
slideMarkupCache = Object.create(null);
|
slideMarkupCache = Object.create(null);
|
||||||
@@ -53,18 +88,45 @@ function showCurrent() {
|
|||||||
applyPendingPlaylistUpdate();
|
applyPendingPlaylistUpdate();
|
||||||
const activeSlides = getCurrentActiveSlides();
|
const activeSlides = getCurrentActiveSlides();
|
||||||
syncWebpagePreloads(activeSlides, index);
|
syncWebpagePreloads(activeSlides, index);
|
||||||
if (typeof syncRtmpPreloads === 'function') {
|
if (typeof syncRtmpWarmups === 'function') {
|
||||||
syncRtmpPreloads(activeSlides, index);
|
syncRtmpWarmups(activeSlides, index);
|
||||||
}
|
}
|
||||||
if (!activeSlides.length) {
|
if (!activeSlides.length) {
|
||||||
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
renderEmpty(slides.length ? 'No slides are scheduled for this time.' : 'No slides assigned to this screen yet.');
|
||||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
renderSlideAtIndex(activeSlides, index);
|
void renderSlideAtIndex(activeSlides, index);
|
||||||
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
lastRenderedViewKey = getCurrentRenderKey(activeSlides);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip the current slide when an RTMP feed is unavailable and the playlist asks for it.
|
||||||
|
function handleRtmpPlaybackFailure(message, options) {
|
||||||
|
if (!currentPlaylistSkipUnavailableRtmp) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var silent = Boolean(options && options.silent);
|
||||||
|
const activeSlides = getCurrentActiveSlides();
|
||||||
|
if (!activeSlides.length) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSlideTimer();
|
||||||
|
if (!silent) {
|
||||||
|
logDebug('Skipping RTMP slide because the stream is unavailable.', String(message || ''), 'error');
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextIndex = (Number(index || 0) + 1) % activeSlides.length;
|
||||||
|
window.setTimeout(function () {
|
||||||
|
if (!getCurrentActiveSlides().length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
void renderSlideAtIndex(getCurrentActiveSlides(), nextIndex);
|
||||||
|
}, 0);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch the latest playlist and queue any updates.
|
// Fetch the latest playlist and queue any updates.
|
||||||
function refresh() {
|
function refresh() {
|
||||||
var request = new XMLHttpRequest();
|
var request = new XMLHttpRequest();
|
||||||
@@ -85,7 +147,7 @@ function refresh() {
|
|||||||
markRefreshHealthy();
|
markRefreshHealthy();
|
||||||
setOfflineBannerVisible(false);
|
setOfflineBannerVisible(false);
|
||||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -105,10 +167,12 @@ function refresh() {
|
|||||||
const nextSignature = getPlaylistRevision(data);
|
const nextSignature = getPlaylistRevision(data);
|
||||||
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
const nextSlides = Array.isArray(data.slides) ? data.slides.map(normalizeSlide) : [];
|
||||||
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
const nextFadeBetweenSlides = Boolean(data && data.playlist && data.playlist.fade_between_slides);
|
||||||
|
const nextSkipUnavailableRtmp = Boolean(data && data.playlist && data.playlist.skip_unavailable_rtmp);
|
||||||
savePlaylistSnapshot({
|
savePlaylistSnapshot({
|
||||||
slides: nextSlides,
|
slides: nextSlides,
|
||||||
signature: nextSignature,
|
signature: nextSignature,
|
||||||
fadeBetweenSlides: nextFadeBetweenSlides,
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
|
skipUnavailableRtmp: nextSkipUnavailableRtmp,
|
||||||
etag: responseEtag
|
etag: responseEtag
|
||||||
});
|
});
|
||||||
markRefreshHealthy();
|
markRefreshHealthy();
|
||||||
@@ -121,6 +185,7 @@ function refresh() {
|
|||||||
slides = nextSlides;
|
slides = nextSlides;
|
||||||
currentPlaylistSignature = nextSignature;
|
currentPlaylistSignature = nextSignature;
|
||||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||||
|
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||||
index = 0;
|
index = 0;
|
||||||
showCurrent();
|
showCurrent();
|
||||||
sendCommandState(lastRenderedSlide);
|
sendCommandState(lastRenderedSlide);
|
||||||
@@ -128,18 +193,19 @@ function refresh() {
|
|||||||
}
|
}
|
||||||
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
|
if (nextSignature === currentPlaylistSignature || (pendingPlaylistUpdate && nextSignature === pendingPlaylistUpdate.signature)) {
|
||||||
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
if (currentActiveSlides.length < 2 && lastRenderedSlide) {
|
||||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
syncWebpagePreloads(getActiveSlidesFrom(nextSlides), index);
|
||||||
if (typeof syncRtmpPreloads === 'function') {
|
if (typeof syncRtmpWarmups === 'function') {
|
||||||
syncRtmpPreloads(getActiveSlidesFrom(nextSlides), index);
|
syncRtmpWarmups(getActiveSlidesFrom(nextSlides), index);
|
||||||
}
|
}
|
||||||
if (currentActiveSlides.length < 2) {
|
if (currentActiveSlides.length < 2) {
|
||||||
slides = nextSlides;
|
slides = nextSlides;
|
||||||
currentPlaylistSignature = nextSignature;
|
currentPlaylistSignature = nextSignature;
|
||||||
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
currentPlaylistFadeBetweenSlides = nextFadeBetweenSlides;
|
||||||
|
currentPlaylistSkipUnavailableRtmp = nextSkipUnavailableRtmp;
|
||||||
pendingPlaylistUpdate = null;
|
pendingPlaylistUpdate = null;
|
||||||
index = 0;
|
index = 0;
|
||||||
showCurrent();
|
showCurrent();
|
||||||
@@ -149,7 +215,8 @@ function refresh() {
|
|||||||
pendingPlaylistUpdate = {
|
pendingPlaylistUpdate = {
|
||||||
slides: nextSlides,
|
slides: nextSlides,
|
||||||
signature: nextSignature,
|
signature: nextSignature,
|
||||||
fadeBetweenSlides: nextFadeBetweenSlides
|
fadeBetweenSlides: nextFadeBetweenSlides,
|
||||||
|
skipUnavailableRtmp: nextSkipUnavailableRtmp
|
||||||
};
|
};
|
||||||
logDebug('Playlist update detected; applying on next slide transition.');
|
logDebug('Playlist update detected; applying on next slide transition.');
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
@@ -171,7 +238,7 @@ function refresh() {
|
|||||||
setOfflineBannerVisible(true);
|
setOfflineBannerVisible(true);
|
||||||
scheduleRefreshRetry();
|
scheduleRefreshRetry();
|
||||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.ontimeout = function () {
|
request.ontimeout = function () {
|
||||||
@@ -183,7 +250,7 @@ function refresh() {
|
|||||||
setOfflineBannerVisible(true);
|
setOfflineBannerVisible(true);
|
||||||
scheduleRefreshRetry();
|
scheduleRefreshRetry();
|
||||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
||||||
scheduleSlideAdvance(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000);
|
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
request.send();
|
request.send();
|
||||||
|
|||||||
@@ -411,6 +411,22 @@ function getTemplateLayout(template) {
|
|||||||
return layout;
|
return layout;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build a dark backdrop style for template and media canvases.
|
||||||
|
function buildBackdropStyle(backgroundColor, backgroundImagePath) {
|
||||||
|
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||||
|
var style = 'background-color:' + escapeHtml(color) + ';';
|
||||||
|
var gradient = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||||
|
|
||||||
|
if (backgroundImagePath) {
|
||||||
|
style += 'background-image:' + gradient + ',url("' + escapeHtml(backgroundImagePath) + '");';
|
||||||
|
style += 'background-position:center,center;background-size:100% 100%,cover;background-repeat:no-repeat,no-repeat;';
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
|
style += 'background-image:' + gradient + ';background-position:center;background-size:100% 100%;background-repeat:no-repeat;';
|
||||||
|
return style;
|
||||||
|
}
|
||||||
|
|
||||||
// Build the cache key for a template render plan.
|
// Build the cache key for a template render plan.
|
||||||
function getTemplateRenderPlanCacheKey(template) {
|
function getTemplateRenderPlanCacheKey(template) {
|
||||||
return getTemplateLayoutCacheKey(template);
|
return getTemplateLayoutCacheKey(template);
|
||||||
@@ -436,6 +452,9 @@ function getTemplateRenderPlan(template) {
|
|||||||
if (region.regionType === 'image') {
|
if (region.regionType === 'image') {
|
||||||
return renderImageRegion(region, regionContent);
|
return renderImageRegion(region, regionContent);
|
||||||
}
|
}
|
||||||
|
if (region.regionType === 'video') {
|
||||||
|
return renderVideoRegion(region, regionContent);
|
||||||
|
}
|
||||||
if (region.regionType === 'webpage') {
|
if (region.regionType === 'webpage') {
|
||||||
return renderWebpageRegion(region, regionContent);
|
return renderWebpageRegion(region, regionContent);
|
||||||
}
|
}
|
||||||
@@ -469,7 +488,7 @@ function renderTemplateSlideMarkup(slide) {
|
|||||||
const regionContent = content[region.regionKey] || {};
|
const regionContent = content[region.regionKey] || {};
|
||||||
return plan.renderRegion(region, regionContent);
|
return plan.renderRegion(region, regionContent);
|
||||||
}).join('') : '';
|
}).join('') : '';
|
||||||
const stageStyle = layout ? 'background-color:' + escapeHtml(layout.backgroundColor || '#111111') + ';' : '';
|
const stageStyle = layout ? buildBackdropStyle(layout.backgroundColor || '#111111', template.background_image_path) : '';
|
||||||
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
|
return renderSlideShell(slide, '', (layout ? layout.canvasWidth : 0) + 'px', (layout ? layout.canvasHeight : 0) + 'px', '<div class="template-stage" style="' + stageStyle + '">' + (layout ? layout.background : '') + regions + '</div>');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -486,22 +505,36 @@ function renderMediaSlideContent(slide) {
|
|||||||
function renderMediaSlideMarkup(slide) {
|
function renderMediaSlideMarkup(slide) {
|
||||||
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
|
const canvasSize = fitCanvasSize(16, 9, window.innerWidth, window.innerHeight);
|
||||||
const media = renderMediaSlideContent(slide);
|
const media = renderMediaSlideContent(slide);
|
||||||
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media);
|
const canvasStyle = slide.kind === 'image' ? buildBackdropStyle('#111111', slide.media_url) : '';
|
||||||
|
return renderSlideShell(slide, 'slide-media', canvasSize.width + 'px', canvasSize.height + 'px', media, canvasStyle);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Render the shared slide shell around slide-specific inner content.
|
// Render the shared slide shell around slide-specific inner content.
|
||||||
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
|
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml, canvasStyle) {
|
||||||
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
|
||||||
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
|
||||||
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>';
|
const style = 'width:' + canvasWidth + ';height:' + canvasHeight + ';' + (canvasStyle || '');
|
||||||
|
return '<div class="slide"><div class="' + className + '" style="' + style + '">' + innerHtml + body + '</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the cache key for rendered slide markup.
|
// Build the cache key for rendered slide markup.
|
||||||
function getSlideMarkupCacheKey(slide) {
|
function getSlideMarkupCacheKey(slide) {
|
||||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
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.
|
// Look up a previously rendered slide in the cache.
|
||||||
function getCachedSlideMarkup(slide) {
|
function getCachedSlideMarkup(slide) {
|
||||||
var cacheKey = getSlideMarkupCacheKey(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 PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
||||||
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
||||||
const MEDIA_CACHE = `pulse-signage-player-media-${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' });
|
return new Response('', { status: 504, statusText: 'Offline' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function networkOnly(request) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('install', function (event) {
|
self.addEventListener('install', function (event) {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
event.waitUntil(Promise.resolve());
|
event.waitUntil(Promise.resolve());
|
||||||
@@ -126,7 +130,7 @@ self.addEventListener('fetch', function (event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/media/')) {
|
if (url.pathname.startsWith('/media/')) {
|
||||||
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE));
|
event.respondWith(networkOnly(request));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+707
-109
@@ -1,20 +1,296 @@
|
|||||||
function renderRtmpRegion(region, regionContent) {
|
function renderRtmpRegion(region, regionContent) {
|
||||||
var url = String(regionContent.value || '').trim();
|
var url = String(regionContent.value || '').trim();
|
||||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
var skipUnavailable = Boolean(currentPlaylistSkipUnavailableRtmp);
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>';
|
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><div class="template-region-placeholder">RTMP stream</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
return '<div class="template-region rtmp" style="' + region.baseStyle + '"><video class="template-region-rtmp-video" data-rtmp-source="' + escapeHtml(url) + '" data-rtmp-disable-audio="' + (disableAudio ? '1' : '0') + '" data-rtmp-skip-unavailable="' + (skipUnavailable ? '1' : '0') + '" autoplay playsinline preload="auto" tabindex="-1" disablepictureinpicture></video><div class="template-region-placeholder template-region-rtmp-placeholder">Loading RTMP stream...</div></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRtmpSessionUrl(sourceUrl, disableAudio) {
|
function getRtmpSessionUrl(sourceUrl, disableAudio) {
|
||||||
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
|
return '/api/rtmp/session?source=' + encodeURIComponent(sourceUrl) + '&disableAudio=' + (disableAudio ? '1' : '0');
|
||||||
}
|
}
|
||||||
|
|
||||||
var rtmpPreloadSignature = '';
|
var rtmpAvailabilityCache = Object.create(null);
|
||||||
|
var rtmpAvailabilityRetryMs = 5000;
|
||||||
|
var rtmpWarmupTimers = Object.create(null);
|
||||||
|
var rtmpBrowserReadyCache = Object.create(null);
|
||||||
|
var rtmpBrowserWarmupTimers = Object.create(null);
|
||||||
|
var rtmpBrowserWarmupContainer = null;
|
||||||
|
var rtmpBrowserWarmupStates = Object.create(null);
|
||||||
|
|
||||||
function getRtmpPreloadSlides(sourceSlides, targetIndex) {
|
function getRtmpAvailabilityKey(sourceUrl, disableAudio) {
|
||||||
|
return String(sourceUrl || '').trim() + '\n' + (disableAudio ? '1' : '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRtmpAvailability(sourceUrl, disableAudio, available) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
rtmpAvailabilityCache[key] = {
|
||||||
|
available: available === null ? null : Boolean(available),
|
||||||
|
pending: available === null,
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpAvailability(sourceUrl, disableAudio) {
|
||||||
|
return rtmpAvailabilityCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpAvailabilityStale(status) {
|
||||||
|
if (!status || !status.checkedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpSlideUnavailable(slide) {
|
||||||
|
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = slide.content || {};
|
||||||
|
return slide.template.regions.some(function (region) {
|
||||||
|
if (region.region_type !== 'rtmp') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionContent = content[region.region_key] || {};
|
||||||
|
var sourceUrl = String(regionContent.value || '').trim();
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
var serverStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||||
|
return Boolean(!serverStatus || isRtmpAvailabilityStale(serverStatus) || serverStatus.available !== true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRtmpAvailabilityForSlide(slide) {
|
||||||
|
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = slide.content || {};
|
||||||
|
slide.template.regions.forEach(function (region) {
|
||||||
|
if (region.region_type !== 'rtmp') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionContent = content[region.region_key] || {};
|
||||||
|
var sourceUrl = String(regionContent.value || '').trim();
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRtmpBrowserReady(sourceUrl, disableAudio, ready) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
rtmpBrowserReadyCache[key] = {
|
||||||
|
ready: ready === null ? null : Boolean(ready),
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpBrowserReady(sourceUrl, disableAudio) {
|
||||||
|
return rtmpBrowserReadyCache[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpBrowserReadyStale(status) {
|
||||||
|
if (!status || !status.checkedAt) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return Date.now() - Number(status.checkedAt || 0) >= rtmpAvailabilityRetryMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureRtmpBrowserWarmupContainer() {
|
||||||
|
if (rtmpBrowserWarmupContainer) {
|
||||||
|
return rtmpBrowserWarmupContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
rtmpBrowserWarmupContainer = document.createElement('div');
|
||||||
|
rtmpBrowserWarmupContainer.className = 'rtmp-browser-warmups';
|
||||||
|
rtmpBrowserWarmupContainer.setAttribute('aria-hidden', 'true');
|
||||||
|
document.body.appendChild(rtmpBrowserWarmupContainer);
|
||||||
|
return rtmpBrowserWarmupContainer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||||
|
return rtmpBrowserWarmupStates[getRtmpAvailabilityKey(sourceUrl, disableAudio)] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearRtmpBrowserWarmupState(sourceUrl, disableAudio) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
var state = rtmpBrowserWarmupStates[key];
|
||||||
|
if (!state) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (state.video && state.video.__rtmpBrowserWarmupTimer) {
|
||||||
|
window.clearTimeout(state.video.__rtmpBrowserWarmupTimer);
|
||||||
|
state.video.__rtmpBrowserWarmupTimer = null;
|
||||||
|
}
|
||||||
|
if (state.video && state.video.__rtmpHls) {
|
||||||
|
try {
|
||||||
|
state.video.__rtmpHls.destroy();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
state.video.__rtmpHls = null;
|
||||||
|
}
|
||||||
|
if (state.wrapper && state.wrapper.parentNode) {
|
||||||
|
state.wrapper.parentNode.removeChild(state.wrapper);
|
||||||
|
}
|
||||||
|
delete rtmpBrowserWarmupStates[key];
|
||||||
|
}
|
||||||
|
|
||||||
|
function warmupRtmpBrowser(sourceUrl, disableAudio) {
|
||||||
|
var key = getRtmpAvailabilityKey(sourceUrl, disableAudio);
|
||||||
|
var existing = rtmpBrowserWarmupStates[key];
|
||||||
|
if (existing) {
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
|
|
||||||
|
var wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'rtmp-browser-warmup-entry';
|
||||||
|
wrapper.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
var video = document.createElement('video');
|
||||||
|
video.className = 'template-region-rtmp-video';
|
||||||
|
video.dataset.rtmpSource = sourceUrl;
|
||||||
|
video.dataset.rtmpDisableAudio = disableAudio ? '1' : '0';
|
||||||
|
video.autoplay = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.preload = 'auto';
|
||||||
|
video.tabIndex = -1;
|
||||||
|
video.disablePictureInPicture = true;
|
||||||
|
video.muted = disableAudio;
|
||||||
|
|
||||||
|
wrapper.appendChild(video);
|
||||||
|
ensureRtmpBrowserWarmupContainer().appendChild(wrapper);
|
||||||
|
|
||||||
|
var state = {
|
||||||
|
wrapper: wrapper,
|
||||||
|
video: video,
|
||||||
|
sourceUrl: sourceUrl,
|
||||||
|
disableAudio: disableAudio
|
||||||
|
};
|
||||||
|
rtmpBrowserWarmupStates[key] = state;
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||||
|
|
||||||
|
var markReady = function () {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, true);
|
||||||
|
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||||
|
};
|
||||||
|
|
||||||
|
var markNotReady = function () {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('canplay', markReady, { once: true });
|
||||||
|
video.addEventListener('playing', markReady, { once: true });
|
||||||
|
video.addEventListener('error', markNotReady, { once: true });
|
||||||
|
|
||||||
|
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||||
|
var hls = new window.Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
liveSyncDurationCount: 4,
|
||||||
|
liveMaxLatencyDurationCount: 8,
|
||||||
|
maxBufferLength: 10,
|
||||||
|
maxLiveSyncPlaybackRate: 1,
|
||||||
|
backBufferLength: 10
|
||||||
|
});
|
||||||
|
video.__rtmpHls = hls;
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||||
|
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||||
|
});
|
||||||
|
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||||
|
if (data && data.fatal) {
|
||||||
|
markNotReady();
|
||||||
|
clearRtmpBrowserWarmupState(sourceUrl, disableAudio);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
requestWarmupPlaylist(sourceUrl, disableAudio, video, markNotReady);
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
markNotReady();
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure) {
|
||||||
|
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result || !result.response) {
|
||||||
|
throw new Error('Unable to initialize RTMP stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = result.response;
|
||||||
|
var payload = result.payload || null;
|
||||||
|
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 503 && payload && payload.probing) {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, null);
|
||||||
|
if (video && !video.__rtmpBrowserWarmupTimer) {
|
||||||
|
video.__rtmpBrowserWarmupTimer = window.setTimeout(function () {
|
||||||
|
video.__rtmpBrowserWarmupTimer = null;
|
||||||
|
requestWarmupPlaylist(sourceUrl, disableAudio, video, onFailure);
|
||||||
|
}, 500);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw new Error('Unable to initialize RTMP stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!playlistUrl) {
|
||||||
|
throw new Error('RTMP playlist URL was not returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||||
|
|
||||||
|
if (video.__rtmpHls && window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||||
|
video.__rtmpHls.loadSource(playlistUrl);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video) {
|
||||||
|
video.src = playlistUrl;
|
||||||
|
video.play().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}).catch(function () {
|
||||||
|
setRtmpBrowserReady(sourceUrl, disableAudio, false);
|
||||||
|
if (typeof onFailure === 'function') {
|
||||||
|
onFailure();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRtmpWarmupSlides(sourceSlides, targetIndex) {
|
||||||
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
var availableSlides = Array.isArray(sourceSlides) ? sourceSlides : [];
|
||||||
if (!availableSlides.length) {
|
if (!availableSlides.length) {
|
||||||
return [];
|
return [];
|
||||||
@@ -25,21 +301,21 @@ function getRtmpPreloadSlides(sourceSlides, targetIndex) {
|
|||||||
normalizedIndex = 0;
|
normalizedIndex = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
var preloadSlides = [];
|
var warmupSlides = [];
|
||||||
var currentSlide = availableSlides[normalizedIndex];
|
var currentSlide = availableSlides[normalizedIndex];
|
||||||
var nextSlide = availableSlides[normalizedIndex + 1];
|
var nextSlide = availableSlides[normalizedIndex + 1];
|
||||||
|
|
||||||
if (currentSlide) {
|
if (currentSlide) {
|
||||||
preloadSlides.push(currentSlide);
|
warmupSlides.push(currentSlide);
|
||||||
}
|
}
|
||||||
if (nextSlide && nextSlide !== currentSlide) {
|
if (nextSlide && nextSlide !== currentSlide) {
|
||||||
preloadSlides.push(nextSlide);
|
warmupSlides.push(nextSlide);
|
||||||
}
|
}
|
||||||
|
|
||||||
return preloadSlides;
|
return warmupSlides;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRtmpPreloadEntries(sourceSlides) {
|
function getRtmpWarmupEntries(sourceSlides) {
|
||||||
var entries = [];
|
var entries = [];
|
||||||
var seen = Object.create(null);
|
var seen = Object.create(null);
|
||||||
|
|
||||||
@@ -50,20 +326,24 @@ function getRtmpPreloadEntries(sourceSlides) {
|
|||||||
if (region.region_type !== 'rtmp') {
|
if (region.region_type !== 'rtmp') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var regionContent = content[region.region_key] || {};
|
var regionContent = content[region.region_key] || {};
|
||||||
var url = String(regionContent.value || '').trim();
|
var url = String(regionContent.value || '').trim();
|
||||||
if (!url) {
|
if (!url) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
var key = url + '\n' + (disableAudio ? '1' : '0');
|
var key = getRtmpAvailabilityKey(url, disableAudio);
|
||||||
if (seen[key]) {
|
if (seen[key]) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
seen[key] = true;
|
seen[key] = true;
|
||||||
entries.push({
|
entries.push({
|
||||||
url: url,
|
url: url,
|
||||||
disableAudio: disableAudio
|
disableAudio: disableAudio,
|
||||||
|
key: key
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -71,30 +351,397 @@ function getRtmpPreloadEntries(sourceSlides) {
|
|||||||
return entries;
|
return entries;
|
||||||
}
|
}
|
||||||
|
|
||||||
function syncRtmpPreloads(sourceSlides, targetIndex) {
|
function syncRtmpWarmups(sourceSlides, targetIndex) {
|
||||||
var entries = getRtmpPreloadEntries(getRtmpPreloadSlides(sourceSlides, targetIndex));
|
var entries = getRtmpWarmupEntries(getRtmpWarmupSlides(sourceSlides, targetIndex));
|
||||||
var signature = entries.map(function (entry) {
|
|
||||||
return entry.url + '\n' + (entry.disableAudio ? '1' : '0');
|
|
||||||
}).join('\n');
|
|
||||||
|
|
||||||
if (signature === rtmpPreloadSignature) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
rtmpPreloadSignature = signature;
|
|
||||||
if (!entries.length) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
entries.forEach(function (entry) {
|
entries.forEach(function (entry) {
|
||||||
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
var status = getRtmpAvailability(entry.url, entry.disableAudio);
|
||||||
credentials: 'same-origin'
|
if (status && !isRtmpAvailabilityStale(status) && status.available === true) {
|
||||||
}).catch(function () {
|
return;
|
||||||
return null;
|
}
|
||||||
});
|
|
||||||
|
if (rtmpWarmupTimers[entry.key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestWarmup() {
|
||||||
|
fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
rtmpWarmupTimers[entry.key] = null;
|
||||||
|
if (!result || !result.response) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
rtmpWarmupTimers[entry.key] = window.setTimeout(function () {
|
||||||
|
rtmpWarmupTimers[entry.key] = null;
|
||||||
|
requestWarmup();
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||||
|
}).catch(function () {
|
||||||
|
rtmpWarmupTimers[entry.key] = null;
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
requestWarmup();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function probeRtmpSlideAvailability(slide) {
|
||||||
|
var entries = getRtmpWarmupEntries([slide]);
|
||||||
|
if (!entries.length) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var results = await Promise.all(entries.map(function (entry) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
return fetch(getRtmpSessionUrl(entry.url, entry.disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result || !result.response) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result.response.ok) {
|
||||||
|
if (result.response.status === 503 && result.payload && result.payload.probing) {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, null);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, true);
|
||||||
|
return true;
|
||||||
|
}).catch(function () {
|
||||||
|
setRtmpAvailability(entry.url, entry.disableAudio, false);
|
||||||
|
return false;
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
|
||||||
|
return results.every(function (value) {
|
||||||
|
return Boolean(value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncRtmpBrowserWarmups(sourceSlides, targetIndex) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRtmpBrowserUnavailable(slide) {
|
||||||
|
if (!slide || !slide.template || !Array.isArray(slide.template.regions)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var content = slide.content || {};
|
||||||
|
return slide.template.regions.some(function (region) {
|
||||||
|
if (region.region_type !== 'rtmp') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionContent = content[region.region_key] || {};
|
||||||
|
var sourceUrl = String(regionContent.value || '').trim();
|
||||||
|
if (!sourceUrl) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
var disableAudio = regionContent.disable_audio === undefined ? true : Boolean(regionContent.disable_audio);
|
||||||
|
var status = getRtmpBrowserReady(sourceUrl, disableAudio);
|
||||||
|
return Boolean(!status || isRtmpBrowserReadyStale(status) || status.ready !== true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function bindRtmpVideoPlaceholder(video, placeholder) {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var markReady = function () {
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'none';
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
video.addEventListener('canplay', markReady, { once: true });
|
||||||
|
video.addEventListener('playing', markReady, { once: true });
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
markReady();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, isPreload, onFailure) {
|
||||||
|
var startupTimeoutMs = skipUnavailable ? 6000 : 0;
|
||||||
|
var startupTimer = null;
|
||||||
|
var probeRetryTimer = null;
|
||||||
|
var webReceiveTimer = null;
|
||||||
|
var webReceiveRetryUsed = false;
|
||||||
|
|
||||||
|
function failPlayback(message, silent) {
|
||||||
|
if (video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
video.dataset.rtmpFailureHandled = '1';
|
||||||
|
if (typeof onFailure === 'function') {
|
||||||
|
onFailure(message, silent);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isPreload && skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
placeholder.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearStartupTimer() {
|
||||||
|
if (startupTimer) {
|
||||||
|
window.clearTimeout(startupTimer);
|
||||||
|
startupTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpStartupTimer) {
|
||||||
|
video.__rtmpStartupTimer = null;
|
||||||
|
}
|
||||||
|
if (probeRetryTimer) {
|
||||||
|
window.clearTimeout(probeRetryTimer);
|
||||||
|
probeRetryTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpProbeRetryTimer) {
|
||||||
|
video.__rtmpProbeRetryTimer = null;
|
||||||
|
}
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
window.clearTimeout(webReceiveTimer);
|
||||||
|
webReceiveTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function tryPlay() {
|
||||||
|
if (video && typeof video.play === 'function') {
|
||||||
|
video.play().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markReceiving() {
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
window.clearTimeout(webReceiveTimer);
|
||||||
|
webReceiveTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleReceiveRetry() {
|
||||||
|
if (isPreload || webReceiveRetryUsed || video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webReceiveTimer = window.setTimeout(function () {
|
||||||
|
webReceiveTimer = null;
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
if (video.dataset.rtmpFailureHandled === '1' || webReceiveRetryUsed) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
webReceiveRetryUsed = true;
|
||||||
|
clearStartupTimer();
|
||||||
|
try {
|
||||||
|
if (video.__rtmpHls) {
|
||||||
|
video.__rtmpHls.destroy();
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
video.__rtmpHls = null;
|
||||||
|
requestSession();
|
||||||
|
}, 1200);
|
||||||
|
video.__rtmpWebReceiveTimer = webReceiveTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryWebReceiveOrFail(message, silent) {
|
||||||
|
if (isPreload || video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!webReceiveRetryUsed) {
|
||||||
|
webReceiveRetryUsed = true;
|
||||||
|
clearStartupTimer();
|
||||||
|
if (webReceiveTimer) {
|
||||||
|
window.clearTimeout(webReceiveTimer);
|
||||||
|
webReceiveTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
|
window.setTimeout(function () {
|
||||||
|
requestSession();
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
failPlayback(message, silent);
|
||||||
|
}
|
||||||
|
|
||||||
|
video.dataset.rtmpInitialized = '1';
|
||||||
|
video.muted = disableAudio;
|
||||||
|
video.controls = false;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.autoplay = true;
|
||||||
|
|
||||||
|
bindRtmpVideoPlaceholder(video, placeholder);
|
||||||
|
video.addEventListener('canplay', markReceiving, { once: true });
|
||||||
|
video.addEventListener('playing', markReceiving, { once: true });
|
||||||
|
|
||||||
|
if (startupTimeoutMs > 0) {
|
||||||
|
startupTimer = window.setTimeout(function () {
|
||||||
|
startupTimer = null;
|
||||||
|
video.__rtmpStartupTimer = null;
|
||||||
|
failPlayback('Unable to load RTMP stream.', true);
|
||||||
|
}, startupTimeoutMs);
|
||||||
|
video.__rtmpStartupTimer = startupTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function retryWhileProbing() {
|
||||||
|
if (probeRetryTimer || video.dataset.rtmpFailureHandled === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
probeRetryTimer = window.setTimeout(function () {
|
||||||
|
probeRetryTimer = null;
|
||||||
|
video.__rtmpProbeRetryTimer = null;
|
||||||
|
requestSession();
|
||||||
|
}, 500);
|
||||||
|
video.__rtmpProbeRetryTimer = probeRetryTimer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function requestSession() {
|
||||||
|
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
return response.json().catch(function () {
|
||||||
|
return null;
|
||||||
|
}).then(function (payload) {
|
||||||
|
return {
|
||||||
|
response: response,
|
||||||
|
payload: payload
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}).then(function (result) {
|
||||||
|
if (!result || !result.response) {
|
||||||
|
throw new Error('Unable to initialize RTMP stream.');
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = result.response;
|
||||||
|
var payload = result.payload || null;
|
||||||
|
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
if (response.status === 503 && payload && payload.probing) {
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, null);
|
||||||
|
retryWhileProbing();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||||
|
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, true);
|
||||||
|
if (!playlistUrl) {
|
||||||
|
throw new Error('RTMP playlist URL was not returned.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
||||||
|
var hls = new window.Hls({
|
||||||
|
enableWorker: true,
|
||||||
|
lowLatencyMode: true,
|
||||||
|
liveSyncDurationCount: 4,
|
||||||
|
liveMaxLatencyDurationCount: 8,
|
||||||
|
maxBufferLength: 20,
|
||||||
|
maxLiveSyncPlaybackRate: 1,
|
||||||
|
backBufferLength: 30
|
||||||
|
});
|
||||||
|
video.__rtmpHls = hls;
|
||||||
|
hls.attachMedia(video);
|
||||||
|
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
||||||
|
hls.loadSource(playlistUrl);
|
||||||
|
scheduleReceiveRetry();
|
||||||
|
});
|
||||||
|
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
|
||||||
|
tryPlay();
|
||||||
|
});
|
||||||
|
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
||||||
|
if (data && data.fatal) {
|
||||||
|
clearStartupTimer();
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||||
|
try {
|
||||||
|
hls.destroy();
|
||||||
|
} catch (_error) {
|
||||||
|
// ignore cleanup errors
|
||||||
|
}
|
||||||
|
video.__rtmpHls = null;
|
||||||
|
retryWebReceiveOrFail('RTMP playback failed.', true);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
||||||
|
video.src = playlistUrl;
|
||||||
|
tryPlay();
|
||||||
|
scheduleReceiveRetry();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
failPlayback('RTMP playback is not supported in this browser.', true);
|
||||||
|
return null;
|
||||||
|
}).catch(function () {
|
||||||
|
clearStartupTimer();
|
||||||
|
setRtmpAvailability(sourceUrl, disableAudio, false);
|
||||||
|
retryWebReceiveOrFail('Unable to load RTMP stream.', true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
requestSession();
|
||||||
|
}
|
||||||
|
|
||||||
function syncRtmpRegions(root) {
|
function syncRtmpRegions(root) {
|
||||||
if (!root) {
|
if (!root) {
|
||||||
return;
|
return;
|
||||||
@@ -118,93 +765,32 @@ function syncRtmpRegions(root) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
video.dataset.rtmpInitialized = '1';
|
var skipUnavailable = String(video.dataset.rtmpSkipUnavailable || '0') === '1';
|
||||||
video.muted = disableAudio;
|
var startupTimer = null;
|
||||||
video.controls = false;
|
|
||||||
video.playsInline = true;
|
|
||||||
video.autoplay = true;
|
|
||||||
|
|
||||||
var markReady = function () {
|
if (skipUnavailable) {
|
||||||
if (placeholder) {
|
var preflightStatus = getRtmpAvailability(sourceUrl, disableAudio);
|
||||||
placeholder.style.display = 'none';
|
if (preflightStatus && !isRtmpAvailabilityStale(preflightStatus) && preflightStatus.available === false) {
|
||||||
}
|
failPlayback('Unable to load RTMP stream.', true);
|
||||||
};
|
|
||||||
|
|
||||||
var tryPlay = function () {
|
|
||||||
if (video && typeof video.play === 'function') {
|
|
||||||
video.play().catch(function () {
|
|
||||||
return null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
video.addEventListener('canplay', markReady, { once: true });
|
|
||||||
video.addEventListener('playing', markReady, { once: true });
|
|
||||||
|
|
||||||
fetch(getRtmpSessionUrl(sourceUrl, disableAudio), {
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Unable to initialize RTMP stream.');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}).then(function (payload) {
|
|
||||||
var playlistUrl = payload && payload.playlistUrl ? String(payload.playlistUrl).trim() : '';
|
|
||||||
if (!playlistUrl) {
|
|
||||||
throw new Error('RTMP playlist URL was not returned.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.Hls && window.Hls.isSupported && window.Hls.isSupported()) {
|
|
||||||
var hls = new window.Hls({
|
|
||||||
enableWorker: true,
|
|
||||||
lowLatencyMode: true,
|
|
||||||
liveSyncDurationCount: 4,
|
|
||||||
liveMaxLatencyDurationCount: 8,
|
|
||||||
maxBufferLength: 20,
|
|
||||||
maxLiveSyncPlaybackRate: 1,
|
|
||||||
backBufferLength: 30
|
|
||||||
});
|
|
||||||
video.__rtmpHls = hls;
|
|
||||||
hls.attachMedia(video);
|
|
||||||
hls.on(window.Hls.Events.MEDIA_ATTACHED, function () {
|
|
||||||
hls.loadSource(playlistUrl);
|
|
||||||
});
|
|
||||||
hls.on(window.Hls.Events.MANIFEST_PARSED, function () {
|
|
||||||
tryPlay();
|
|
||||||
});
|
|
||||||
hls.on(window.Hls.Events.ERROR, function (_event, data) {
|
|
||||||
if (data && data.fatal) {
|
|
||||||
try {
|
|
||||||
hls.destroy();
|
|
||||||
} catch (_error) {
|
|
||||||
// ignore cleanup errors
|
|
||||||
}
|
|
||||||
video.__rtmpHls = null;
|
|
||||||
if (placeholder) {
|
|
||||||
placeholder.style.display = 'flex';
|
|
||||||
placeholder.textContent = 'RTMP playback failed.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (video.canPlayType && video.canPlayType('application/vnd.apple.mpegurl')) {
|
function failPlayback(message, silent) {
|
||||||
video.src = playlistUrl;
|
if (video.dataset.rtmpFailureHandled === '1') {
|
||||||
tryPlay();
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
video.dataset.rtmpFailureHandled = '1';
|
||||||
|
if (skipUnavailable && typeof handleRtmpPlaybackFailure === 'function' && handleRtmpPlaybackFailure(message, { silent: Boolean(silent) })) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (placeholder) {
|
||||||
|
placeholder.style.display = 'flex';
|
||||||
|
placeholder.textContent = message;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (placeholder) {
|
startRtmpPlayback(video, sourceUrl, disableAudio, skipUnavailable, placeholder, false);
|
||||||
placeholder.style.display = 'flex';
|
|
||||||
placeholder.textContent = 'RTMP playback is not supported in this browser.';
|
|
||||||
}
|
|
||||||
}).catch(function (_error) {
|
|
||||||
if (placeholder) {
|
|
||||||
placeholder.style.display = 'flex';
|
|
||||||
placeholder.textContent = 'Unable to load RTMP stream.';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -215,6 +801,18 @@ function destroyRtmpRegions(root) {
|
|||||||
|
|
||||||
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
var videos = root.querySelectorAll('video[data-rtmp-source]');
|
||||||
Array.prototype.forEach.call(videos, function (video) {
|
Array.prototype.forEach.call(videos, function (video) {
|
||||||
|
if (video.__rtmpStartupTimer) {
|
||||||
|
window.clearTimeout(video.__rtmpStartupTimer);
|
||||||
|
video.__rtmpStartupTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpProbeRetryTimer) {
|
||||||
|
window.clearTimeout(video.__rtmpProbeRetryTimer);
|
||||||
|
video.__rtmpProbeRetryTimer = null;
|
||||||
|
}
|
||||||
|
if (video.__rtmpWebReceiveTimer) {
|
||||||
|
window.clearTimeout(video.__rtmpWebReceiveTimer);
|
||||||
|
video.__rtmpWebReceiveTimer = null;
|
||||||
|
}
|
||||||
if (video.__rtmpHls) {
|
if (video.__rtmpHls) {
|
||||||
try {
|
try {
|
||||||
video.__rtmpHls.destroy();
|
video.__rtmpHls.destroy();
|
||||||
|
|||||||
@@ -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 playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||||
const playerRegionScriptPaths = [
|
const playerRegionScriptPaths = [
|
||||||
path.join(__dirname, 'regions', 'image.js'),
|
path.join(__dirname, 'regions', 'image.js'),
|
||||||
|
path.join(__dirname, 'regions', 'video.js'),
|
||||||
path.join(__dirname, 'regions', 'webpage.js'),
|
path.join(__dirname, 'regions', 'webpage.js'),
|
||||||
path.join(__dirname, 'regions', 'html.js'),
|
path.join(__dirname, 'regions', 'html.js'),
|
||||||
path.join(__dirname, 'regions', 'rtmp.js'),
|
path.join(__dirname, 'regions', 'rtmp.js'),
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
const Handlebars = require('handlebars');
|
const Handlebars = require('handlebars');
|
||||||
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
const { mediaKind, safeJsonForScript, getPlayerPageTemplate, getPlayerClientNameScript, getPlayerPageOfflineScript, getPlayerPagePlaylistScript, getPlayerPageCommandsScript, getPlayerPageRenderingScript, getPlayerPagePlaybackScript, getPlayerPageScript, getPlayerRegionScripts, getPlayerOnboardingLandingScript, getPlayerOnboardingFormScript } = require('./render-helpers');
|
||||||
|
const { createThumbnailPreviewBootstrapScript } = require('./thumbnail-preview');
|
||||||
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
|
const { createPageAuthBundle, createPageFetchAuthScript } = require('../request-auth');
|
||||||
|
|
||||||
function renderPage(template, options) {
|
function renderPage(template, options) {
|
||||||
@@ -117,7 +118,7 @@ function renderPlayerPage(slug, initialData) {
|
|||||||
return renderPage(template, {
|
return renderPage(template, {
|
||||||
title: 'Screen ' + slug,
|
title: 'Screen ' + slug,
|
||||||
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
body: '<div id="app"><div class="empty">Loading screen...</div></div>',
|
||||||
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
|
script: createPageFetchAuthScript(pageAuthToken) + hlsScriptTag + serviceWorkerScript + createThumbnailPreviewBootstrapScript(initialData) + '<script>' + offlineScript + '</script>' + '<script>' + playlistScript + '</script>' + '<script>' + commandScript + '</script>' + '<script>' + renderingScript + '</script>' + '<script>' + playbackScript + '</script>' + onboardingScript + script
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+77
-16
@@ -2,6 +2,7 @@ const fs = require('fs');
|
|||||||
const express = require('express');
|
const express = require('express');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
const { getSharedSecret, createPageAuthBundle, verifyPageAuthToken, verifyRequestAuth } = require('../request-auth');
|
||||||
|
const { buildThumbnailPreviewData } = require('./thumbnail-preview');
|
||||||
|
|
||||||
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
const TRANSIENT_DB_ERROR_CODES = ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'EPIPE', 'ENOTFOUND', 'PROTOCOL_CONNECTION_LOST', 'POOL_CLOSED', 'ERR_POOL_CLOSED'];
|
||||||
|
|
||||||
@@ -58,6 +59,21 @@ function registerPlayerRoutes(app, options) {
|
|||||||
next();
|
next();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveMediaFilePath(fileName) {
|
||||||
|
const relativePath = path.normalize(String(fileName || '').trim()).replace(/^([\\/])+/, '');
|
||||||
|
if (!relativePath || relativePath === '.' || relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedMediaDir = path.resolve(mediaDir);
|
||||||
|
const resolvedFilePath = path.resolve(mediaDir, relativePath);
|
||||||
|
if (resolvedFilePath !== resolvedMediaDir && !resolvedFilePath.startsWith(resolvedMediaDir + path.sep)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return resolvedFilePath;
|
||||||
|
}
|
||||||
|
|
||||||
app.use('/assets', express.static(assetDir));
|
app.use('/assets', express.static(assetDir));
|
||||||
app.use('/media', express.static(mediaDir));
|
app.use('/media', express.static(mediaDir));
|
||||||
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
|
app.use('/assets/vendor', express.static(path.join(__dirname, '..', '..', 'node_modules', 'hls.js', 'dist')));
|
||||||
@@ -93,21 +109,21 @@ function registerPlayerRoutes(app, options) {
|
|||||||
|
|
||||||
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
app.get('/api/media/config', requireRequestAuth, function (_req, res) {
|
||||||
res.json({
|
res.json({
|
||||||
mediaDir: mediaDir
|
mediaDir: mediaDir,
|
||||||
|
uploadDir: path.join(mediaDir, 'uploads')
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
const filename = require('path').basename(String(req.params.filename || '').trim());
|
const filePath = resolveMediaFilePath(req.params.filename);
|
||||||
if (!filename) {
|
if (!filePath) {
|
||||||
return res.status(400).json({ error: 'Filename is required' });
|
return res.status(400).json({ error: 'Filename is required' });
|
||||||
}
|
}
|
||||||
const filePath = require('path').join(mediaDir, filename);
|
|
||||||
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
|
||||||
await fs.promises.mkdir(mediaDir, { recursive: true });
|
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
|
||||||
await fs.promises.writeFile(filePath, body);
|
await fs.promises.writeFile(filePath, body);
|
||||||
res.json({ ok: true, filename: filename });
|
res.json({ ok: true, filename: req.params.filename });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -115,11 +131,10 @@ function registerPlayerRoutes(app, options) {
|
|||||||
|
|
||||||
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
|
app.delete('/api/media/:filename', requireRequestAuth, async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const filename = require('path').basename(String(req.params.filename || '').trim());
|
const filePath = resolveMediaFilePath(req.params.filename);
|
||||||
if (!filename) {
|
if (!filePath) {
|
||||||
return res.status(400).json({ error: 'Filename is required' });
|
return res.status(400).json({ error: 'Filename is required' });
|
||||||
}
|
}
|
||||||
const filePath = require('path').join(mediaDir, filename);
|
|
||||||
try {
|
try {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -127,7 +142,7 @@ function registerPlayerRoutes(app, options) {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
res.json({ ok: true, filename: filename });
|
res.json({ ok: true, filename: req.params.filename });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -138,16 +153,25 @@ function registerPlayerRoutes(app, options) {
|
|||||||
const source = String(req.query.source || '').trim();
|
const source = String(req.query.source || '').trim();
|
||||||
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
|
const disableAudio = String(req.query.disableAudio || '').trim().toLowerCase();
|
||||||
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
|
const useMutedOutput = disableAudio === '1' || disableAudio === 'true' || disableAudio === 'yes' || disableAudio === 'on';
|
||||||
const session = await rtmpStreamService.ensureSession(source, useMutedOutput);
|
const status = await rtmpStreamService.getSessionStatus(source, useMutedOutput);
|
||||||
await session.ready.catch(function () {
|
const session = status.session;
|
||||||
return false;
|
const ready = Boolean(status.ready);
|
||||||
});
|
const live = Boolean(status.live);
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
if (!ready || !live) {
|
||||||
|
return res.status(503).json({
|
||||||
|
ready: false,
|
||||||
|
live: false,
|
||||||
|
timedOut: Boolean(status.timedOut),
|
||||||
|
stderr: String(status.stderr || '')
|
||||||
|
});
|
||||||
|
}
|
||||||
res.json({
|
res.json({
|
||||||
key: session.key,
|
key: session.key,
|
||||||
playlistUrl: session.playlistUrl,
|
playlistUrl: session.playlistUrl,
|
||||||
disableAudio: session.disableAudio,
|
disableAudio: session.disableAudio,
|
||||||
ready: true
|
ready: true,
|
||||||
|
live: true
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -192,6 +216,43 @@ function registerPlayerRoutes(app, options) {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.get('/api/internal/slide-thumbnails/:id/preview', requireRequestAuth, async function (req, res, next) {
|
||||||
|
try {
|
||||||
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||||
|
if (!slide) {
|
||||||
|
return res.status(404).send('Slide not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = buildThumbnailPreviewData(slide);
|
||||||
|
|
||||||
|
if (typeof common.fetchRssFeedsData === 'function' && typeof common.fetchRssFeedItemsByFeedId === 'function') {
|
||||||
|
const rssData = await common.fetchRssFeedsData(pool);
|
||||||
|
data.rssFeeds = await Promise.all((rssData.rssFeeds || []).map(async function (feed) {
|
||||||
|
const items = await common.fetchRssFeedItemsByFeedId(pool, feed.id);
|
||||||
|
return Object.assign({}, feed, {
|
||||||
|
items: items.map(function (item) {
|
||||||
|
return typeof common.normalizeRssFeedItem === 'function' ? common.normalizeRssFeedItem(item) : item;
|
||||||
|
})
|
||||||
|
});
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof common.fetchApiSourcesData === 'function') {
|
||||||
|
const apiData = await common.fetchApiSourcesData(pool);
|
||||||
|
data.apiSources = (apiData.apiSources || []).map(function (source) {
|
||||||
|
return Object.assign({}, source, {
|
||||||
|
responseJson: typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(source.last_response_json) : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
res.send(common.renderPlayerPage('slide-thumbnail-preview-' + slide.id, data));
|
||||||
|
} catch (error) {
|
||||||
|
next(error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
app.get('/api/screens/:slug/playlist', requirePageAuth(['player']), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
function buildThumbnailPreviewData(slide) {
|
||||||
|
return {
|
||||||
|
thumbnailPreview: true,
|
||||||
|
screen: {
|
||||||
|
id: slide.id,
|
||||||
|
name: slide.title,
|
||||||
|
slug: 'slide-thumbnail-preview-' + slide.id,
|
||||||
|
playlist_id: null
|
||||||
|
},
|
||||||
|
playlist: {
|
||||||
|
fade_between_slides: false
|
||||||
|
},
|
||||||
|
slides: [slide],
|
||||||
|
rssFeeds: [],
|
||||||
|
apiSources: [],
|
||||||
|
revision: String(slide.modified_at || slide.id || Date.now())
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createThumbnailPreviewBootstrapScript(initialData) {
|
||||||
|
return initialData && initialData.thumbnailPreview ? '<script>window.__pulseThumbnailPreview = true;</script>' : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildThumbnailPreviewData: buildThumbnailPreviewData,
|
||||||
|
createThumbnailPreviewBootstrapScript: createThumbnailPreviewBootstrapScript
|
||||||
|
};
|
||||||
+11
-1
@@ -109,10 +109,20 @@ const PERMISSION_SECTIONS = [
|
|||||||
name: 'Background tasks',
|
name: 'Background tasks',
|
||||||
sectionName: 'Settings',
|
sectionName: 'Settings',
|
||||||
actions: [
|
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: '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',
|
key: 'users',
|
||||||
order: 80,
|
order: 80,
|
||||||
|
|||||||
+46
-5
@@ -47,6 +47,19 @@ function canonicalize(value) {
|
|||||||
return value === undefined ? undefined : value;
|
return value === undefined ? undefined : value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequestPath(pathValue) {
|
||||||
|
const value = String(pathValue || '').trim();
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(value);
|
||||||
|
} catch (_error) {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function stableJson(value) {
|
function stableJson(value) {
|
||||||
return JSON.stringify(canonicalize(value));
|
return JSON.stringify(canonicalize(value));
|
||||||
}
|
}
|
||||||
@@ -56,10 +69,38 @@ function hashPayload(value) {
|
|||||||
return crypto.createHash('sha256').update(normalized).digest('hex');
|
return crypto.createHash('sha256').update(normalized).digest('hex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeRequestAuthBody(req) {
|
||||||
|
if (!req) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const body = req.body;
|
||||||
|
if (body === undefined || body === null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Buffer.isBuffer(body)) {
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof body === 'object' && !Array.isArray(body)) {
|
||||||
|
const hasFields = Object.keys(body).length > 0;
|
||||||
|
if (!hasFields) {
|
||||||
|
const contentLength = Number(String(req.headers && req.headers['content-length'] || '').trim() || 0);
|
||||||
|
const contentType = String(req.headers && req.headers['content-type'] || '').trim();
|
||||||
|
if (!contentLength || !contentType) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return body;
|
||||||
|
}
|
||||||
|
|
||||||
function getRequestPath(req) {
|
function getRequestPath(req) {
|
||||||
const explicitPath = String(req && req.path ? req.path : '').trim();
|
const explicitPath = String(req && req.path ? req.path : '').trim();
|
||||||
if (explicitPath) {
|
if (explicitPath) {
|
||||||
return explicitPath;
|
return normalizeRequestPath(explicitPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawUrl = String(req && req.url ? req.url : '').trim();
|
const rawUrl = String(req && req.url ? req.url : '').trim();
|
||||||
@@ -68,9 +109,9 @@ function getRequestPath(req) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return new URL(rawUrl, 'http://localhost').pathname;
|
return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
|
||||||
} catch (_error) {
|
} catch (_error) {
|
||||||
return rawUrl.split('?')[0] || '';
|
return normalizeRequestPath(rawUrl.split('?')[0] || '');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -167,7 +208,7 @@ function createRequestAuthHeaders(options) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const method = String(options && options.method || 'GET').trim().toUpperCase();
|
const method = String(options && options.method || 'GET').trim().toUpperCase();
|
||||||
const pathname = String(options && options.pathname || '').trim();
|
const pathname = normalizeRequestPath(options && options.pathname || '');
|
||||||
const timestamp = String(options && options.timestamp || Date.now()).trim();
|
const timestamp = String(options && options.timestamp || Date.now()).trim();
|
||||||
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
|
const bodyDigest = hashPayload(options && Object.prototype.hasOwnProperty.call(options, 'body') ? options.body : null);
|
||||||
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
|
const signature = signText(secret, `request\n${method}\n${pathname}\n${timestamp}\n${bodyDigest}`);
|
||||||
@@ -199,7 +240,7 @@ function verifyRequestAuth(req) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(req.body)}`);
|
const expectedSignature = signText(secret, `request\n${String(req.method || 'GET').trim().toUpperCase()}\n${getRequestPath(req)}\n${timestamp}\n${hashPayload(normalizeRequestAuthBody(req))}`);
|
||||||
return timingSafeEqualHex(expectedSignature, signature);
|
return timingSafeEqualHex(expectedSignature, signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+118
-5
@@ -17,6 +17,7 @@ const registerAdminContentRoutes = require('./web/routes/admin/content');
|
|||||||
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
|
const registerAdminDataSourceRoutes = require('./web/routes/data-sources');
|
||||||
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
|
const registerAdminSettingsRoutes = require('./web/routes/settings/background-tasks');
|
||||||
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
|
const { createBackgroundTaskQueue, normalizeIntervalMs } = require('./web/lib/background-task-queue');
|
||||||
|
const { captureSlideThumbnail } = require('./web/lib/slide-thumbnails');
|
||||||
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
|
const { refreshApiSource, refreshRssFeed } = require('./web/lib/data-source-refresh');
|
||||||
const { createWebBootstrap } = require('./web/bootstrap');
|
const { createWebBootstrap } = require('./web/bootstrap');
|
||||||
const { requirePermission } = require('./rbac');
|
const { requirePermission } = require('./rbac');
|
||||||
@@ -24,6 +25,7 @@ const rbacData = require('./web/lib/rbac-data');
|
|||||||
const { createPlayerActionService } = require('./web/lib/player-actions');
|
const { createPlayerActionService } = require('./web/lib/player-actions');
|
||||||
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
|
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
|
||||||
const { createSessionService } = require('./web/lib/session');
|
const { createSessionService } = require('./web/lib/session');
|
||||||
|
const { hasAnyPermission } = require('./rbac');
|
||||||
const {
|
const {
|
||||||
formatDashboardDate,
|
formatDashboardDate,
|
||||||
readArrayField,
|
readArrayField,
|
||||||
@@ -52,7 +54,11 @@ async function start() {
|
|||||||
const pool = common.createPool();
|
const pool = common.createPool();
|
||||||
const PORT = Number(process.env.WEB_PORT || 3000);
|
const PORT = Number(process.env.WEB_PORT || 3000);
|
||||||
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
const MEDIA_DIR = path.join(__dirname, '..', 'media');
|
||||||
|
const UPLOADS_DIR = path.join(MEDIA_DIR, 'uploads');
|
||||||
|
const THUMBNAILS_DIR = path.join(MEDIA_DIR, 'thumbnails');
|
||||||
const ASSET_DIR = path.join(__dirname, 'web', 'public');
|
const ASSET_DIR = path.join(__dirname, 'web', 'public');
|
||||||
|
const WEB_BASE_URL = (process.env.WEB_PUBLIC_BASE_URL || process.env.WEB_BASE_URL || ('http://127.0.0.1:' + PORT)).replace(/\/$/, '');
|
||||||
|
const DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS = Math.max(100, Number(process.env.DATA_SOURCE_STARTUP_REFRESH_STAGGER_MS || 250));
|
||||||
const backgroundTaskQueue = createBackgroundTaskQueue({
|
const backgroundTaskQueue = createBackgroundTaskQueue({
|
||||||
pool: pool,
|
pool: pool,
|
||||||
maxConcurrent: 1
|
maxConcurrent: 1
|
||||||
@@ -83,16 +89,19 @@ async function start() {
|
|||||||
common: common,
|
common: common,
|
||||||
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
playerInternalBaseUrl: PLAYER_INTERNAL_BASE_URL,
|
||||||
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
playerPublicBaseUrl: PLAYER_PUBLIC_BASE_URL,
|
||||||
uploadDir: MEDIA_DIR,
|
uploadDir: UPLOADS_DIR,
|
||||||
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
|
||||||
formatDashboardDate: formatDashboardDate,
|
formatDashboardDate: formatDashboardDate,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
backgroundTaskQueue: backgroundTaskQueue
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
hasAnyPermission: hasAnyPermission,
|
||||||
});
|
});
|
||||||
const upload = webBootstrap.upload;
|
const upload = webBootstrap.upload;
|
||||||
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
|
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
|
||||||
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
|
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
|
||||||
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
|
||||||
|
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
|
||||||
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
|
||||||
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
|
||||||
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
|
||||||
@@ -138,10 +147,30 @@ async function start() {
|
|||||||
throw new Error('Unsupported data source refresh task.');
|
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.urlencoded({ extended: true }));
|
||||||
app.use(express.json());
|
app.use(express.json());
|
||||||
app.use('/assets', express.static(ASSET_DIR));
|
app.use('/assets', express.static(ASSET_DIR));
|
||||||
|
app.use('/assets/vendor/cropperjs', express.static(path.join(__dirname, '..', 'node_modules', 'cropperjs', 'dist')));
|
||||||
app.use('/media', express.static(MEDIA_DIR));
|
app.use('/media', express.static(MEDIA_DIR));
|
||||||
|
fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||||
|
fs.mkdirSync(THUMBNAILS_DIR, { recursive: true });
|
||||||
|
|
||||||
app.use(async function (req, _res, next) {
|
app.use(async function (req, _res, next) {
|
||||||
try {
|
try {
|
||||||
@@ -153,7 +182,7 @@ async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
app.use(function (req, res, next) {
|
app.use(function (req, res, next) {
|
||||||
if (req.path === '/' || req.path === '/login' || req.path === '/logout') {
|
if (req.path === '/' || req.path === '/login' || req.path === '/logout' || req.path.indexOf('/api/internal/slide-thumbnails/') === 0) {
|
||||||
return next();
|
return next();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,17 +284,20 @@ async function start() {
|
|||||||
common: common,
|
common: common,
|
||||||
pages: pages,
|
pages: pages,
|
||||||
upload: upload,
|
upload: upload,
|
||||||
uploadDir: MEDIA_DIR,
|
uploadDir: UPLOADS_DIR,
|
||||||
fetchScreensBySlideId: fetchScreensBySlideId,
|
fetchScreensBySlideId: fetchScreensBySlideId,
|
||||||
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
fetchScreensByTemplateId: fetchScreensByTemplateId,
|
||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||||
getAuditUserId: getAuditUserId,
|
getAuditUserId: getAuditUserId,
|
||||||
redirectAfterSave: redirectAfterSave,
|
redirectAfterSave: redirectAfterSave,
|
||||||
notifyPlayerScreens: notifyPlayerScreens,
|
notifyPlayerScreens: notifyPlayerScreens,
|
||||||
broadcastDashboardState: broadcastDashboardState,
|
broadcastDashboardState: broadcastDashboardState,
|
||||||
|
backgroundTaskQueue: backgroundTaskQueue,
|
||||||
|
hasAnyPermission: hasAnyPermission,
|
||||||
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
|
||||||
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
|
||||||
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
|
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
|
||||||
@@ -340,7 +372,7 @@ async function start() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Ensure schema and mirror media before the web service starts handling traffic.
|
// Ensure schema and mirror media before the web service starts handling traffic.
|
||||||
await common.ensureSchema(pool);
|
await common.ensureSchema(pool, { mediaDir: MEDIA_DIR });
|
||||||
await backgroundTaskQueue.initialize();
|
await backgroundTaskQueue.initialize();
|
||||||
|
|
||||||
async function syncRecurringRefreshes() {
|
async function syncRecurringRefreshes() {
|
||||||
@@ -381,7 +413,88 @@ async function start() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function registerRecurringMaintenanceTasks() {
|
||||||
|
async function runUnusedUploadSweep() {
|
||||||
|
const uploadPaths = await collectUploadPathsFromDirectory(UPLOADS_DIR);
|
||||||
|
if (!uploadPaths.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await removeUnusedUploadFiles(pool, UPLOADS_DIR, uploadPaths);
|
||||||
|
}
|
||||||
|
|
||||||
|
backgroundTaskQueue.registerRecurringTask({
|
||||||
|
key: 'unused-upload-sweep',
|
||||||
|
title: 'Unused upload sweep',
|
||||||
|
category: 'media-sync',
|
||||||
|
intervalMs: 24 * 60 * 60 * 1000,
|
||||||
|
metadata: {
|
||||||
|
uploadDir: UPLOADS_DIR
|
||||||
|
},
|
||||||
|
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 * 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();
|
await syncRecurringRefreshes();
|
||||||
|
await registerRecurringMaintenanceTasks();
|
||||||
|
scheduleInitialDataSourceRefreshes().catch(function (error) {
|
||||||
|
console.warn('Unable to schedule startup data source refreshes:', error);
|
||||||
|
});
|
||||||
|
|
||||||
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
fs.mkdirSync(MEDIA_DIR, { recursive: true });
|
||||||
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
|
await syncExistingUploadsToPlayer(pool, MEDIA_DIR).catch(function (error) {
|
||||||
|
|||||||
Vendored
+4
@@ -120,6 +120,8 @@ function createWebBootstrap(options) {
|
|||||||
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
|
||||||
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
|
||||||
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
|
||||||
|
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
|
||||||
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
|
||||||
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
|
||||||
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
|
||||||
@@ -210,6 +212,8 @@ function createWebBootstrap(options) {
|
|||||||
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
|
||||||
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
|
||||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
|
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||||
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
|
||||||
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
|
||||||
runMediaSyncTask: runMediaSyncTask,
|
runMediaSyncTask: runMediaSyncTask,
|
||||||
|
|||||||
@@ -357,6 +357,16 @@ function createBackgroundTaskQueue(options) {
|
|||||||
scheduleRecurringRun(job, job.intervalMs);
|
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) {
|
function syncRecurringTaskState(task, status, errorMessage) {
|
||||||
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
|
||||||
if (!recurringKey) {
|
if (!recurringKey) {
|
||||||
@@ -600,22 +610,32 @@ function createBackgroundTaskQueue(options) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getTaskSortTime(task) {
|
||||||
|
const finishedTime = task && task.finishedAt ? Date.parse(task.finishedAt) : NaN;
|
||||||
|
if (Number.isFinite(finishedTime)) {
|
||||||
|
return finishedTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
const createdTime = task && task.createdAt ? Date.parse(task.createdAt) : NaN;
|
||||||
|
if (Number.isFinite(createdTime)) {
|
||||||
|
return createdTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
function listTasks() {
|
function listTasks() {
|
||||||
return Array.from(tasksById.values())
|
return Array.from(tasksById.values())
|
||||||
.slice()
|
.slice()
|
||||||
.sort(function (left, right) {
|
.sort(function (left, right) {
|
||||||
const statusRank = {
|
const leftQueuedTime = left && left.createdAt ? Date.parse(left.createdAt) : NaN;
|
||||||
running: 0,
|
const rightQueuedTime = right && right.createdAt ? Date.parse(right.createdAt) : NaN;
|
||||||
queued: 1,
|
if (Number.isFinite(leftQueuedTime) && Number.isFinite(rightQueuedTime) && leftQueuedTime !== rightQueuedTime) {
|
||||||
failed: 2,
|
return rightQueuedTime - leftQueuedTime;
|
||||||
completed: 3,
|
}
|
||||||
canceled: 4
|
|
||||||
};
|
|
||||||
|
|
||||||
const leftRank = Object.prototype.hasOwnProperty.call(statusRank, left.status) ? statusRank[left.status] : 9;
|
if (Number.isFinite(leftQueuedTime) !== Number.isFinite(rightQueuedTime)) {
|
||||||
const rightRank = Object.prototype.hasOwnProperty.call(statusRank, right.status) ? statusRank[right.status] : 9;
|
return Number.isFinite(leftQueuedTime) ? -1 : 1;
|
||||||
if (leftRank !== rightRank) {
|
|
||||||
return leftRank - rightRank;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return right.id - left.id;
|
return right.id - left.id;
|
||||||
@@ -727,6 +747,7 @@ function createBackgroundTaskQueue(options) {
|
|||||||
setTaskHandler: setTaskHandler,
|
setTaskHandler: setTaskHandler,
|
||||||
registerRecurringTask: registerRecurringTask,
|
registerRecurringTask: registerRecurringTask,
|
||||||
removeRecurringTask: removeRecurringTask,
|
removeRecurringTask: removeRecurringTask,
|
||||||
|
runRecurringTask: runRecurringTask,
|
||||||
listTasks: listTasks,
|
listTasks: listTasks,
|
||||||
listRecurringTasks: listRecurringTasks,
|
listRecurringTasks: listRecurringTasks,
|
||||||
getTaskById: getTaskById,
|
getTaskById: getTaskById,
|
||||||
|
|||||||
@@ -33,6 +33,18 @@ function buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboa
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareScreenNames(left, right) {
|
||||||
|
const leftName = String(left && left.name || '').trim();
|
||||||
|
const rightName = String(right && right.name || '').trim();
|
||||||
|
const nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
|
||||||
|
if (nameCompare !== 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
function createDashboardStateService(options) {
|
function createDashboardStateService(options) {
|
||||||
const pool = options && options.pool;
|
const pool = options && options.pool;
|
||||||
const common = options && options.common;
|
const common = options && options.common;
|
||||||
@@ -82,11 +94,13 @@ function createDashboardStateService(options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug).map(function (screen) {
|
const screens = enrichScreensWithConnections(data.screens || [], connectionsBySlug, onboardingNameBySlug)
|
||||||
return Object.assign({}, screen, {
|
.map(function (screen) {
|
||||||
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
return Object.assign({}, screen, {
|
||||||
});
|
player_url: `${playerPublicBaseUrl}/screen/${encodeURIComponent(screen.slug)}`
|
||||||
});
|
});
|
||||||
|
})
|
||||||
|
.sort(compareScreenNames);
|
||||||
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
const clients = buildClientRows(screens, connectionsBySlug, onboardingNameBySlug, onboardingNameByDeviceId, playerPublicBaseUrl, formatDashboardDate);
|
||||||
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
const playerServiceConnected = Array.from(playerSnapshotSockets.values()).some(function (socket) {
|
||||||
return socket && socket.readyState === WebSocket.OPEN;
|
return socket && socket.readyState === WebSocket.OPEN;
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
function normalizePageNumber(value) {
|
||||||
|
const pageNumber = Math.floor(Number(value) || 1);
|
||||||
|
return Math.max(1, pageNumber);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(query) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
Object.keys(query || {}).forEach(function (key) {
|
||||||
|
const value = query[key];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
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);
|
||||||
|
const totalPages = Math.max(1, Math.ceil(Number(totalItems) / normalizedPageSize));
|
||||||
|
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 = 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,
|
||||||
|
totalPages: totalPages,
|
||||||
|
totalItems: Number(totalItems) || 0,
|
||||||
|
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,
|
||||||
|
pageSize: normalizedPageSize,
|
||||||
|
pageParam: normalizedPageParam,
|
||||||
|
itemLabel: String(itemLabel || 'items'),
|
||||||
|
ariaLabel: String(ariaLabel || 'Pagination')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
normalizePageNumber,
|
||||||
|
buildQueryString,
|
||||||
|
buildPaginationPages,
|
||||||
|
buildPagination
|
||||||
|
};
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
const { PERMISSIONS, normalizePermissionKeys } = require('../../rbac');
|
||||||
|
const { fetchPagedRows } = require('../../data/utils');
|
||||||
|
|
||||||
function parseCsvIds(value) {
|
function parseCsvIds(value) {
|
||||||
return String(value || '')
|
return String(value || '')
|
||||||
@@ -27,6 +28,33 @@ async function fetchRoles(pool) {
|
|||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
(SELECT COUNT(*) FROM role_permissions rp WHERE rp.role_id = r.id) AS permission_count
|
||||||
|
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
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ roles: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchRoleById(pool, roleId) {
|
async function fetchRoleById(pool, roleId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
`SELECT r.id, r.role_key, r.name, r.description, r.created_at, r.modified_at,
|
||||||
@@ -106,6 +134,51 @@ async function fetchUsersWithRoles(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchUsersWithRolesPage(pool, page, pageSize, searchTerm, sortKey, sortDirection) {
|
||||||
|
const paged = await fetchPagedRows(pool, {
|
||||||
|
selectSql: `SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||||
|
COALESCE(role_data.role_names, '') AS role_names,
|
||||||
|
COALESCE(role_data.role_ids_csv, '') AS role_ids_csv
|
||||||
|
FROM users u
|
||||||
|
LEFT JOIN (
|
||||||
|
SELECT ur.user_id,
|
||||||
|
GROUP_CONCAT(DISTINCT r.name ORDER BY r.name SEPARATOR ', ') AS role_names,
|
||||||
|
GROUP_CONCAT(DISTINCT r.id ORDER BY r.name SEPARATOR ',') AS role_ids_csv
|
||||||
|
FROM user_roles ur
|
||||||
|
JOIN roles r ON r.id = ur.role_id
|
||||||
|
GROUP BY ur.user_id
|
||||||
|
) role_data ON role_data.user_id = u.id
|
||||||
|
ORDER BY u.id ASC`,
|
||||||
|
countSql: 'SELECT COUNT(*) AS count FROM users',
|
||||||
|
searchColumns: ['u.name', 'u.username', 'role_data.role_names'],
|
||||||
|
searchTerm: searchTerm,
|
||||||
|
sortColumns: {
|
||||||
|
user: 'u.username',
|
||||||
|
name: 'u.name',
|
||||||
|
roles: 'role_names',
|
||||||
|
created: 'u.created_at',
|
||||||
|
modified: 'u.modified_at'
|
||||||
|
},
|
||||||
|
sortKey: sortKey,
|
||||||
|
sortDirection: sortDirection,
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
users: (paged.rows || []).map(function (row) {
|
||||||
|
return Object.assign({}, row, {
|
||||||
|
roleIds: parseCsvIds(row.role_ids_csv),
|
||||||
|
roleNames: String(row.role_names || '').trim()
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
totalItems: paged.totalItems,
|
||||||
|
totalPages: paged.totalPages,
|
||||||
|
currentPage: paged.currentPage,
|
||||||
|
pageSize: paged.pageSize
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async function fetchUserWithRoles(pool, userId) {
|
async function fetchUserWithRoles(pool, userId) {
|
||||||
const [rows] = await pool.query(
|
const [rows] = await pool.query(
|
||||||
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
`SELECT u.id, u.name, u.username, u.created_at, u.modified_at,
|
||||||
@@ -184,11 +257,13 @@ module.exports = {
|
|||||||
PERMISSIONS,
|
PERMISSIONS,
|
||||||
fetchPermissions,
|
fetchPermissions,
|
||||||
fetchRoles,
|
fetchRoles,
|
||||||
|
fetchRolesPage,
|
||||||
fetchRoleById,
|
fetchRoleById,
|
||||||
fetchRolePermissionKeys,
|
fetchRolePermissionKeys,
|
||||||
fetchRoleUserIds,
|
fetchRoleUserIds,
|
||||||
fetchRolesForUser,
|
fetchRolesForUser,
|
||||||
fetchUsersWithRoles,
|
fetchUsersWithRoles,
|
||||||
|
fetchUsersWithRolesPage,
|
||||||
fetchUserWithRoles,
|
fetchUserWithRoles,
|
||||||
syncUserRoles,
|
syncUserRoles,
|
||||||
syncRoleUsers,
|
syncRoleUsers,
|
||||||
|
|||||||
@@ -0,0 +1,291 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const puppeteer = require('puppeteer-core');
|
||||||
|
const chromiumModule = require('@sparticuz/chromium');
|
||||||
|
const sharp = require('sharp');
|
||||||
|
const chromium = chromiumModule && typeof chromiumModule.executablePath === 'function'
|
||||||
|
? chromiumModule
|
||||||
|
: chromiumModule && chromiumModule.default && typeof chromiumModule.default.executablePath === 'function'
|
||||||
|
? chromiumModule.default
|
||||||
|
: chromiumModule;
|
||||||
|
const {
|
||||||
|
escapeHtml,
|
||||||
|
mediaKind,
|
||||||
|
renderEditorJsContent,
|
||||||
|
sanitizeFontFamily,
|
||||||
|
sanitizeFontSize,
|
||||||
|
sanitizeTextColor
|
||||||
|
} = require('../../player/render-helpers');
|
||||||
|
const { createRequestAuthHeaders } = require('../../request-auth');
|
||||||
|
|
||||||
|
const SYSTEM_CHROMIUM_PATHS = [
|
||||||
|
process.env.PUPPETEER_EXECUTABLE_PATH,
|
||||||
|
process.env.CHROMIUM_PATH,
|
||||||
|
'/usr/bin/chromium',
|
||||||
|
'/usr/bin/chromium-browser',
|
||||||
|
'/usr/local/bin/chromium',
|
||||||
|
'/snap/bin/chromium'
|
||||||
|
].filter(Boolean);
|
||||||
|
const PLAYER_VIEWPORT = {
|
||||||
|
width: 1920,
|
||||||
|
height: 1080,
|
||||||
|
deviceScaleFactor: 1
|
||||||
|
};
|
||||||
|
const THUMBNAIL_MAX_SIZE = {
|
||||||
|
width: 480,
|
||||||
|
height: 270
|
||||||
|
};
|
||||||
|
|
||||||
|
function normalizeBaseUrl(baseUrl) {
|
||||||
|
return String(baseUrl || '').trim().replace(/\/$/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAssetUrl(baseUrl, value) {
|
||||||
|
const raw = String(value || '').trim();
|
||||||
|
if (!raw) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (/^(?:https?:)?\/\//i.test(raw) || raw.startsWith('data:')) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
const normalizedBaseUrl = normalizeBaseUrl(baseUrl);
|
||||||
|
if (!normalizedBaseUrl) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
if (raw.startsWith('/')) {
|
||||||
|
return normalizedBaseUrl + raw;
|
||||||
|
}
|
||||||
|
return normalizedBaseUrl + '/' + raw.replace(/^\/+/, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCanvasSize(slide) {
|
||||||
|
const template = slide && slide.template ? slide.template : null;
|
||||||
|
return {
|
||||||
|
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
|
||||||
|
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionContent(slide, region) {
|
||||||
|
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
||||||
|
return content && typeof content === 'object' ? content : { value: content };
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasVisibleContent(html) {
|
||||||
|
return Boolean(String(html || '').replace(/<[^>]+>/g, '').trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTextRegionMarkup(region, regionContent) {
|
||||||
|
const fontFamily = sanitizeFontFamily(regionContent.font_family || region.font_family);
|
||||||
|
const fontSize = sanitizeFontSize(regionContent.font_size || region.font_size);
|
||||||
|
const fontColor = sanitizeTextColor(regionContent.font_color || region.font_color);
|
||||||
|
const renderedBody = renderEditorJsContent(regionContent.value || '');
|
||||||
|
if (!hasVisibleContent(renderedBody)) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="template-region text" style="' + region.baseStyle + '"><div class="template-region-text-scale" style="width:' + region.pixelWidth + 'px;height:' + region.pixelHeight + 'px;' + (fontFamily ? 'font-family:' + escapeHtml(fontFamily) + ';' : '') + 'font-size:' + fontSize + 'px;color:' + escapeHtml(fontColor || '#000000') + ';">' + renderedBody + '</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRegionInnerHtml(region, regionContent, baseUrl) {
|
||||||
|
const regionType = String(regionContent.type || region.region_type || 'text').trim().toLowerCase();
|
||||||
|
const rawValue = regionContent && regionContent.value !== undefined ? regionContent.value : '';
|
||||||
|
|
||||||
|
if (regionType === 'image') {
|
||||||
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
|
return src
|
||||||
|
? '<img src="' + escapeHtml(src) + '" alt="' + escapeHtml(region.label || region.region_key || 'image') + '" />'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
? '<iframe src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'webpage') + '" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'html') {
|
||||||
|
const html = String(rawValue || '').trim();
|
||||||
|
return html
|
||||||
|
? '<iframe sandbox="" 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="' + escapeHtml(region.label || region.region_key || 'html') + '" loading="eager" scrolling="no"></iframe>'
|
||||||
|
: '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (regionType === 'rtmp') {
|
||||||
|
const label = String(rawValue || '').trim() || 'RTMP source';
|
||||||
|
return '<div class="template-region-rtmp-placeholder">' + escapeHtml(label) + '</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildTextRegionMarkup(region, regionContent);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function launchBrowser() {
|
||||||
|
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
|
||||||
|
return fs.existsSync(candidate);
|
||||||
|
}) || '';
|
||||||
|
const usingSystemChromium = Boolean(executablePath);
|
||||||
|
|
||||||
|
if (!executablePath && chromium && typeof chromium.executablePath === 'function') {
|
||||||
|
executablePath = await chromium.executablePath();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!executablePath || !fs.existsSync(executablePath)) {
|
||||||
|
throw new Error('Chromium executable was not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (usingSystemChromium) {
|
||||||
|
return puppeteer.launch({
|
||||||
|
args: [
|
||||||
|
'--no-sandbox',
|
||||||
|
'--disable-setuid-sandbox',
|
||||||
|
'--disable-dev-shm-usage',
|
||||||
|
'--disable-gpu'
|
||||||
|
],
|
||||||
|
defaultViewport: { width: 1920, height: 1080, deviceScaleFactor: 1 },
|
||||||
|
executablePath: executablePath,
|
||||||
|
headless: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return puppeteer.launch({
|
||||||
|
args: puppeteer.defaultArgs({
|
||||||
|
args: chromium && chromium.args ? chromium.args : [],
|
||||||
|
headless: 'shell'
|
||||||
|
}),
|
||||||
|
defaultViewport: chromium && chromium.defaultViewport ? chromium.defaultViewport : null,
|
||||||
|
executablePath: executablePath,
|
||||||
|
headless: 'shell'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function captureSlideThumbnail(options) {
|
||||||
|
const pool = options && options.pool;
|
||||||
|
const common = options && options.common;
|
||||||
|
const mediaDir = String(options && options.mediaDir || '').trim();
|
||||||
|
const baseUrl = normalizeBaseUrl(options && options.baseUrl);
|
||||||
|
const slideId = Number(options && options.slideId || 0);
|
||||||
|
const previousThumbnailPath = String(options && options.previousThumbnailPath || '').trim();
|
||||||
|
|
||||||
|
if (!pool || !common || !mediaDir || !Number.isFinite(slideId) || slideId <= 0) {
|
||||||
|
throw new Error('captureSlideThumbnail requires pool, common, mediaDir, and slideId.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const slide = await common.fetchSlideById(pool, slideId);
|
||||||
|
if (!slide) {
|
||||||
|
throw new Error('Slide not found.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvasSize = getCanvasSize(slide);
|
||||||
|
const thumbnailDir = path.join(mediaDir, 'thumbnails');
|
||||||
|
const thumbnailRelativePath = String(slide.thumbnail_path || '').trim() || '/media/thumbnails/slides/slide-' + slide.id + '.png';
|
||||||
|
const filePath = path.join(mediaDir, thumbnailRelativePath.replace(/^\/+media\//, ''));
|
||||||
|
const fullSizePath = filePath.replace(/\.png$/i, '.full.png');
|
||||||
|
const thumbnailTempPath = filePath.replace(/\.png$/i, '.tmp.png');
|
||||||
|
const thumbnailPath = thumbnailRelativePath;
|
||||||
|
|
||||||
|
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();
|
||||||
|
try {
|
||||||
|
const previewPath = '/api/internal/slide-thumbnails/' + slide.id + '/preview';
|
||||||
|
const previewUrl = baseUrl + previewPath;
|
||||||
|
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
|
||||||
|
method: 'GET',
|
||||||
|
pathname: previewPath
|
||||||
|
}));
|
||||||
|
await page.setViewport(PLAYER_VIEWPORT);
|
||||||
|
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
|
||||||
|
await waitForThumbnailRender(page);
|
||||||
|
const canvas = await page.$('.slide-canvas');
|
||||||
|
if (!canvas) {
|
||||||
|
throw new Error('Player render did not produce a slide canvas.');
|
||||||
|
}
|
||||||
|
await canvas.screenshot({ path: fullSizePath });
|
||||||
|
} finally {
|
||||||
|
await page.close().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await browser.close().catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
await sharp(fullSizePath)
|
||||||
|
.resize({
|
||||||
|
width: THUMBNAIL_MAX_SIZE.width,
|
||||||
|
height: THUMBNAIL_MAX_SIZE.height,
|
||||||
|
fit: 'inside',
|
||||||
|
withoutEnlargement: true
|
||||||
|
})
|
||||||
|
.png()
|
||||||
|
.toFile(thumbnailTempPath);
|
||||||
|
|
||||||
|
await fs.promises.rm(filePath, { force: true });
|
||||||
|
await fs.promises.rename(thumbnailTempPath, filePath);
|
||||||
|
await fs.promises.unlink(fullSizePath).catch(function (error) {
|
||||||
|
if (!error || error.code !== 'ENOENT') {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await pool.query('UPDATE slides SET thumbnail_path = ? WHERE id = ?', [thumbnailPath, slide.id]);
|
||||||
|
return {
|
||||||
|
slideId: slide.id,
|
||||||
|
thumbnailPath: thumbnailPath,
|
||||||
|
filePath: filePath,
|
||||||
|
fullSizePath: fullSizePath,
|
||||||
|
mediaKind: mediaKind(slide.media_path || '')
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
captureSlideThumbnail: captureSlideThumbnail
|
||||||
|
};
|
||||||
+102
-18
@@ -15,6 +15,7 @@ function createUploadSyncService(options) {
|
|||||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
|
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
let playerUploadSyncMode = null;
|
let playerUploadSyncMode = null;
|
||||||
let playerUploadSyncModePromise = null;
|
let playerUploadSyncModePromise = null;
|
||||||
@@ -40,7 +41,12 @@ function createUploadSyncService(options) {
|
|||||||
cb(null, `${stamp}${safeExt}`);
|
cb(null, `${stamp}${safeExt}`);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return multer({ storage });
|
return multer({
|
||||||
|
storage: storage,
|
||||||
|
limits: {
|
||||||
|
fileSize: MAX_UPLOAD_BYTES
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeUploadReference(uploadPath) {
|
function normalizeUploadReference(uploadPath) {
|
||||||
@@ -51,6 +57,32 @@ function createUploadSyncService(options) {
|
|||||||
return value;
|
return value;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUploadRelativePath(uploadPath) {
|
||||||
|
const value = normalizeUploadReference(uploadPath);
|
||||||
|
if (!value) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return value.replace(/^\/media\//, '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveUploadFilePath(uploadDir, uploadPath) {
|
||||||
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
|
if (!relativePath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
|
||||||
|
if (!normalizedUploadDir) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (relativePath.startsWith('uploads/')) {
|
||||||
|
return path.join(normalizedUploadDir, relativePath.slice('uploads/'.length));
|
||||||
|
}
|
||||||
|
|
||||||
|
return path.join(path.dirname(normalizedUploadDir), relativePath);
|
||||||
|
}
|
||||||
|
|
||||||
function collectUploadReferencesFromValue(value, refs) {
|
function collectUploadReferencesFromValue(value, refs) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return refs;
|
return refs;
|
||||||
@@ -134,7 +166,7 @@ function createUploadSyncService(options) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filePath = path.join(uploadDir, path.basename(uploadPath));
|
const filePath = resolveUploadFilePath(uploadDir, uploadPath);
|
||||||
try {
|
try {
|
||||||
await fs.promises.unlink(filePath);
|
await fs.promises.unlink(filePath);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -150,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) {
|
async function getPlayerUploadSyncMode(localUploadDir) {
|
||||||
if (playerUploadSyncMode) {
|
if (playerUploadSyncMode) {
|
||||||
return playerUploadSyncMode;
|
return playerUploadSyncMode;
|
||||||
@@ -174,7 +251,7 @@ function createUploadSyncService(options) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
const playerUploadDir = data && data.mediaDir ? normalizeUploadRoot(data.mediaDir) : null;
|
const playerUploadDir = data && (data.uploadDir || data.mediaDir) ? normalizeUploadRoot(data.uploadDir || data.mediaDir) : null;
|
||||||
if (!playerUploadDir) {
|
if (!playerUploadDir) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -232,8 +309,11 @@ function createUploadSyncService(options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = path.basename(uploadPath);
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
const sourcePath = path.join(localUploadDir, filename);
|
const sourcePath = resolveUploadFilePath(localUploadDir, uploadPath);
|
||||||
|
if (!relativePath || !sourcePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
let fileBuffer = null;
|
let fileBuffer = null;
|
||||||
try {
|
try {
|
||||||
fileBuffer = await fs.promises.readFile(sourcePath);
|
fileBuffer = await fs.promises.readFile(sourcePath);
|
||||||
@@ -247,10 +327,10 @@ function createUploadSyncService(options) {
|
|||||||
try {
|
try {
|
||||||
const authHeaders = createRequestAuthHeaders({
|
const authHeaders = createRequestAuthHeaders({
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
pathname: `/api/media/${encodeURIComponent(filename)}`,
|
pathname: `/api/media/${encodeURIComponent(relativePath)}`,
|
||||||
body: fileBuffer
|
body: fileBuffer
|
||||||
});
|
});
|
||||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/octet-stream',
|
'Content-Type': 'application/octet-stream',
|
||||||
@@ -259,12 +339,12 @@ function createUploadSyncService(options) {
|
|||||||
body: fileBuffer
|
body: fileBuffer
|
||||||
});
|
});
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
console.warn('Unable to sync upload to player:', filename, response.status, response.statusText);
|
console.warn('Unable to sync upload to player:', relativePath, response.status, response.statusText);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Unable to sync upload to player:', filename, error);
|
console.warn('Unable to sync upload to player:', relativePath, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -274,13 +354,16 @@ function createUploadSyncService(options) {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
const filename = path.basename(uploadPath);
|
const relativePath = getUploadRelativePath(uploadPath);
|
||||||
|
if (!relativePath) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const authHeaders = createRequestAuthHeaders({
|
const authHeaders = createRequestAuthHeaders({
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
pathname: `/api/media/${encodeURIComponent(filename)}`
|
pathname: `/api/media/${encodeURIComponent(relativePath)}`
|
||||||
});
|
});
|
||||||
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(filename)}`, {
|
const response = await fetch(`${playerInternalBaseUrl}/api/media/${encodeURIComponent(relativePath)}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: {
|
headers: {
|
||||||
Accept: 'application/json',
|
Accept: 'application/json',
|
||||||
@@ -288,12 +371,12 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (!response.ok && response.status !== 404) {
|
if (!response.ok && response.status !== 404) {
|
||||||
console.warn('Unable to remove upload from player:', filename, response.status, response.statusText);
|
console.warn('Unable to remove upload from player:', relativePath, response.status, response.statusText);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('Unable to remove upload from player:', filename, error);
|
console.warn('Unable to remove upload from player:', relativePath, error);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -544,6 +627,10 @@ function createUploadSyncService(options) {
|
|||||||
if (mode === 'playlist') {
|
if (mode === 'playlist') {
|
||||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||||
|
|
||||||
|
if (operation.nextUploadRefs.length) {
|
||||||
|
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||||
|
}
|
||||||
|
|
||||||
if (operation.previousUploadRefs.length) {
|
if (operation.previousUploadRefs.length) {
|
||||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||||
@@ -551,10 +638,6 @@ function createUploadSyncService(options) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.nextUploadRefs.length) {
|
|
||||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation.refreshScreenSlugs.length) {
|
if (operation.refreshScreenSlugs.length) {
|
||||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||||
if (refreshTargets.ready.length) {
|
if (refreshTargets.ready.length) {
|
||||||
@@ -607,6 +690,7 @@ function createUploadSyncService(options) {
|
|||||||
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
|
||||||
countUploadReferences: countUploadReferences,
|
countUploadReferences: countUploadReferences,
|
||||||
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
removeUnusedUploadFiles: removeUnusedUploadFiles,
|
||||||
|
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
|
||||||
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
|
||||||
shouldMirrorUploads: shouldMirrorUploads,
|
shouldMirrorUploads: shouldMirrorUploads,
|
||||||
queuePlayerUploadSync: queuePlayerUploadSync,
|
queuePlayerUploadSync: queuePlayerUploadSync,
|
||||||
|
|||||||
@@ -33,7 +33,8 @@ module.exports = {
|
|||||||
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
|
||||||
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
|
||||||
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
|
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'),
|
renderErrorPage: require('./error'),
|
||||||
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
|
||||||
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
|
||||||
|
|||||||
@@ -168,6 +168,32 @@
|
|||||||
top: 12px;
|
top: 12px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card-header .card-tools > .btn,
|
||||||
|
.card-header .card-tools > a.btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header .card-tools .input-group {
|
||||||
|
flex: 1 1 11rem;
|
||||||
|
min-width: 8rem;
|
||||||
|
max-width: 14rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-tasks-task-tools {
|
||||||
|
flex-wrap: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-tasks-task-search {
|
||||||
|
flex: 0 1 12rem;
|
||||||
|
min-width: 9rem;
|
||||||
|
max-width: 12rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.background-tasks-task-status {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
.admin-form-card {
|
.admin-form-card {
|
||||||
box-shadow: none;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
@@ -241,7 +267,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-actions-card .card-header,
|
.dashboard-actions-card .card-header,
|
||||||
.dashboard-table-card .card-header {
|
.dashboard-screen-card-shell .card-header {
|
||||||
padding-top: 1rem;
|
padding-top: 1rem;
|
||||||
padding-bottom: 1rem;
|
padding-bottom: 1rem;
|
||||||
}
|
}
|
||||||
@@ -283,6 +309,107 @@
|
|||||||
font-size: 0.875rem;
|
font-size: 0.875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-card-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-card-header .dashboard-card-heading {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-tile {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.95rem;
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: calc(var(--bs-border-radius) + 0.25rem);
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-tile-top {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-tile-text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-name {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 1rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-link {
|
||||||
|
display: inline-block;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-pill {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
padding: 0.3rem 0.6rem;
|
||||||
|
border-radius: 999px;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-pill.is-live {
|
||||||
|
background: rgba(25, 135, 84, 0.12);
|
||||||
|
color: var(--bs-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-pill.is-idle {
|
||||||
|
background: rgba(108, 117, 125, 0.12);
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta div {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta dt {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-meta dd {
|
||||||
|
margin: 0.15rem 0 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-screen-empty {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0.25rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -733,6 +860,13 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slide-preview-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-preview-background {
|
.slide-preview-background {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -761,6 +895,246 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
z-index: 0;
|
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 {
|
.template-designer-layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: minmax(0, 1fr) 20rem;
|
grid-template-columns: minmax(0, 1fr) 20rem;
|
||||||
@@ -777,7 +1151,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
overflow: hidden;
|
/* overflow: hidden; */
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--bs-body-bg);
|
background: var(--bs-body-bg);
|
||||||
@@ -806,7 +1180,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
|
|
||||||
.designer-overlay {
|
.designer-overlay {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
cursor: crosshair;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.designer-rect {
|
.designer-rect {
|
||||||
@@ -814,9 +1188,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
border: 2px solid rgba(13, 110, 253, 0.95);
|
border: 2px solid rgba(13, 110, 253, 0.95);
|
||||||
background: rgba(13, 110, 253, 0.12);
|
background: rgba(13, 110, 253, 0.12);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 0.35rem;
|
border-radius: 0;
|
||||||
min-width: 12px;
|
min-width: 12px;
|
||||||
min-height: 12px;
|
min-height: 12px;
|
||||||
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
.designer-rect.selected {
|
.designer-rect.selected {
|
||||||
@@ -826,12 +1201,12 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
|
|
||||||
.designer-rect-label {
|
.designer-rect-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0.4rem;
|
||||||
top: -1.55rem;
|
top: 0.35rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0.15rem 0.45rem;
|
padding: 0.15rem 0.45rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(33, 37, 41, 0.88);
|
background: rgba(33, 37, 41, 0.92);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -855,26 +1230,30 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.nw {
|
.resize-handle.nw {
|
||||||
left: -0.4rem;
|
left: 0;
|
||||||
top: -0.4rem;
|
top: 0;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.ne {
|
.resize-handle.ne {
|
||||||
right: -0.4rem;
|
right: 0;
|
||||||
top: -0.4rem;
|
top: 0;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.sw {
|
.resize-handle.sw {
|
||||||
left: -0.4rem;
|
left: 0;
|
||||||
bottom: -0.4rem;
|
bottom: 0;
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.se {
|
.resize-handle.se {
|
||||||
right: -0.4rem;
|
right: 0;
|
||||||
bottom: -0.4rem;
|
bottom: 0;
|
||||||
|
transform: translate(50%, 50%);
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -958,7 +1337,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-info-card .card-title {
|
.region-info-card .card-title {
|
||||||
@@ -1028,7 +1406,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 0.75rem;
|
gap: 0;
|
||||||
min-height: 3.25rem;
|
min-height: 3.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1104,8 +1482,14 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-item .remove-region {
|
.region-item [data-region-remove-button] {
|
||||||
justify-self: end;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-item [data-region-remove-button]:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-item .form-control:focus,
|
.region-item .form-control:focus,
|
||||||
@@ -1182,6 +1566,173 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-modal-body {
|
||||||
|
display: grid;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-toolbar {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 1;
|
||||||
|
padding-bottom: 0.25rem;
|
||||||
|
background: var(--bs-modal-bg, var(--bs-body-bg));
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||||
|
gap: 1.25rem;
|
||||||
|
max-height: min(64vh, 48rem);
|
||||||
|
overflow: auto;
|
||||||
|
padding: 0.25rem 0.5rem 0.5rem 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card {
|
||||||
|
appearance: none;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 1.1rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
padding: 0;
|
||||||
|
display: grid;
|
||||||
|
gap: 0.3rem;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card:hover,
|
||||||
|
.playlist-slide-picker-card:focus-visible {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.15);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
outline: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-selected {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-assigned,
|
||||||
|
.playlist-slide-picker-card:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.55;
|
||||||
|
transform: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-assigned {
|
||||||
|
border-color: rgba(var(--bs-primary-rgb), 0.35);
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-media {
|
||||||
|
filter: saturate(0.85) grayscale(0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-title {
|
||||||
|
color: var(--bs-body-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-media {
|
||||||
|
position: relative;
|
||||||
|
aspect-ratio: 16 / 9;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 1.05rem 1.05rem 0 0;
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-thumb-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-thumb-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 0.35rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.35) 0%, rgba(255, 255, 255, 0.12) 100%),
|
||||||
|
repeating-linear-gradient(45deg, rgba(108, 117, 125, 0.08), rgba(108, 117, 125, 0.08) 12px, rgba(108, 117, 125, 0.04) 12px, rgba(108, 117, 125, 0.04) 24px);
|
||||||
|
font-size: 0.8rem;
|
||||||
|
text-align: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-thumb-placeholder-icon {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-thumb-placeholder-label {
|
||||||
|
display: block;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-title {
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.25;
|
||||||
|
min-height: 0;
|
||||||
|
font-size: 0.98rem;
|
||||||
|
padding: 0 0.9rem 0.6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-filter-button {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-check {
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 0.5rem;
|
||||||
|
z-index: 1;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
opacity: 0;
|
||||||
|
background: rgba(var(--bs-body-bg-rgb), 0.88);
|
||||||
|
border-radius: 999px;
|
||||||
|
padding: 0.2rem 0.45rem;
|
||||||
|
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-selected .playlist-slide-picker-check {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-badge {
|
||||||
|
display: none;
|
||||||
|
position: absolute;
|
||||||
|
left: 0.5rem;
|
||||||
|
top: 0.5rem;
|
||||||
|
z-index: 1;
|
||||||
|
font-size: 0.65rem;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
text-transform: uppercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-card.is-assigned .playlist-slide-picker-badge {
|
||||||
|
display: inline-flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-picker-empty.is-hidden {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
.schedule-day-grid .schedule-day-button {
|
.schedule-day-grid .schedule-day-button {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
}
|
}
|
||||||
@@ -1242,10 +1793,95 @@ td[data-label="Actions"] > div {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-duration-input {
|
||||||
|
width: 6rem;
|
||||||
|
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) > * > * {
|
table.table > :not(caption) > * > * {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-slide-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-cell-content {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-title {
|
||||||
|
display: block;
|
||||||
|
line-height: 1.25;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-thumb {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
max-width: 5.5rem;
|
||||||
|
max-height: 5.5rem;
|
||||||
|
aspect-ratio: var(--playlist-slide-thumb-aspect-ratio, 16 / 9);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: rgba(15, 23, 42, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-thumb-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-slide-thumb-placeholder {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 5.5rem;
|
||||||
|
min-height: 3.5rem;
|
||||||
|
color: var(--bs-secondary-color, #6c757d);
|
||||||
|
background:
|
||||||
|
linear-gradient(135deg, rgba(255, 255, 255, 0.06) 0%, rgba(255, 255, 255, 0.02) 100%),
|
||||||
|
rgba(15, 23, 42, 0.18);
|
||||||
|
font-size: 2.25rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-order-cell {
|
.playlist-order-cell {
|
||||||
width: 1%;
|
width: 1%;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
@@ -1328,16 +1964,6 @@ table.table thead th.sort-desc .table-sort-indicator {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-hero-body {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-hero-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 332 KiB After Width: | Height: | Size: 19 KiB |
@@ -1,594 +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;
|
|
||||||
}
|
|
||||||
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 () {
|
||||||
|
var webUiHelpers = window.webUiHelpers || {};
|
||||||
function escapeHtml(value) {
|
var escapeHtml = webUiHelpers.escapeHtml;
|
||||||
return String(value ?? '')
|
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||||
.replace(/&/g, '&')
|
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||||
.replace(/</g, '<')
|
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||||
.replace(/>/g, '>')
|
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||||
.replace(/"/g, '"')
|
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||||
.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() {
|
function initConfirmForms() {
|
||||||
document.addEventListener('submit', function (event) {
|
document.addEventListener('submit', function (event) {
|
||||||
@@ -234,6 +161,9 @@
|
|||||||
if (typeof window.initLocalDateTimes === 'function') {
|
if (typeof window.initLocalDateTimes === 'function') {
|
||||||
window.initLocalDateTimes(targetElement);
|
window.initLocalDateTimes(targetElement);
|
||||||
}
|
}
|
||||||
|
if (typeof window.initTableSearches === 'function') {
|
||||||
|
window.initTableSearches(targetElement);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||||
@@ -268,7 +198,7 @@
|
|||||||
|
|
||||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
currentTarget.outerHTML = nextTarget.outerHTML;
|
||||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
||||||
}).catch(function (_error) {
|
}).catch(function () {
|
||||||
// Ignore refresh replacement failures and leave the existing content in place.
|
// Ignore refresh replacement failures and leave the existing content in place.
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -434,6 +364,11 @@
|
|||||||
window.location.replace(response.url);
|
window.location.replace(response.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
||||||
|
clearFormDirty(form);
|
||||||
|
window.location.replace(response.url || window.location.href);
|
||||||
|
return;
|
||||||
|
}
|
||||||
clearFormDirty(form);
|
clearFormDirty(form);
|
||||||
return response.text().then(function (text) {
|
return response.text().then(function (text) {
|
||||||
var savedMessage = '';
|
var savedMessage = '';
|
||||||
@@ -577,8 +512,37 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (window.initSortableTables) {
|
function initTableSearches(root) {
|
||||||
window.initSortableTables();
|
if (typeof window.initTableSearches === 'function') {
|
||||||
|
window.initTableSearches(root);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createSlideThumbPlaceholder() {
|
||||||
|
var placeholder = document.createElement('span');
|
||||||
|
var icon = document.createElement('i');
|
||||||
|
|
||||||
|
placeholder.className = 'playlist-slide-thumb-placeholder';
|
||||||
|
icon.className = 'bi bi-image';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
placeholder.appendChild(icon);
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSlideThumbFallbacks(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('.playlist-slide-thumb-image'), function (image) {
|
||||||
|
if (image.getAttribute('data-slide-thumb-fallback-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
image.setAttribute('data-slide-thumb-fallback-bound', 'true');
|
||||||
|
image.addEventListener('error', function () {
|
||||||
|
if (image.parentNode) {
|
||||||
|
image.parentNode.replaceChild(createSlideThumbPlaceholder(), image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
initConfirmForms();
|
initConfirmForms();
|
||||||
@@ -589,6 +553,10 @@
|
|||||||
initSubmitOnChange();
|
initSubmitOnChange();
|
||||||
initJsonTogglePanels();
|
initJsonTogglePanels();
|
||||||
initLocalDateTimes();
|
initLocalDateTimes();
|
||||||
|
initTableSearches();
|
||||||
|
attachSlideThumbFallbacks(document);
|
||||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||||
window.initLocalDateTimes = initLocalDateTimes;
|
window.initLocalDateTimes = initLocalDateTimes;
|
||||||
|
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 () {
|
||||||
function escapeHtml(value) {
|
var webUiHelpers = window.webUiHelpers || {};
|
||||||
return String(value ?? '')
|
var escapeHtml = webUiHelpers.escapeHtml;
|
||||||
.replace(/&/g, '&')
|
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||||
.replace(/</g, '<')
|
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||||
.replace(/>/g, '>')
|
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||||
.replace(/"/g, '"')
|
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||||
.replace(/'/g, ''');
|
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||||
}
|
|
||||||
|
|
||||||
function formatDashboardDate(value) {
|
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
var date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientRowKey(client) {
|
|
||||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientDisplayName(client) {
|
|
||||||
if (client && client.client_name) {
|
|
||||||
return String(client.client_name).trim();
|
|
||||||
}
|
|
||||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
|
||||||
if (clientId) {
|
|
||||||
return clientId;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (button.classList) {
|
|
||||||
classesToRemove.forEach(function (className) {
|
|
||||||
button.classList.remove(className);
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
button.classList.add(classToAdd);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className = String(button.className || '');
|
|
||||||
classesToRemove.forEach(function (removeClass) {
|
|
||||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
className += ' ' + classToAdd;
|
|
||||||
}
|
|
||||||
button.className = className.replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDisplayIp(value) {
|
|
||||||
var ip = String(value || '').trim();
|
|
||||||
if (!ip) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
|
||||||
return ip.slice(7).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderClientActionCell(client) {
|
function renderClientActionCell(client) {
|
||||||
var paused = Boolean(client.paused);
|
var paused = Boolean(client.paused);
|
||||||
@@ -91,7 +19,7 @@
|
|||||||
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
var reloadConfirmMessage = 'Reloading will restart the player page. Continue?';
|
||||||
var blackoutCommandValue = blackout ? 'false' : 'true';
|
var blackoutCommandValue = blackout ? 'false' : 'true';
|
||||||
|
|
||||||
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-outline-secondary" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi bi-' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi bi-' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
return '<div class="actions justify-content-end"><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-confirm-message="' + escapeHtml(reloadConfirmMessage) + '" data-async-command><input type="hidden" name="command" value="reload" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-danger" data-action="reload"><i class="bi bi-arrow-repeat me-1" aria-hidden="true"></i>Reload</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="previous" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="previous" aria-label="Previous slide"><i class="bi bi-skip-backward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="next" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="btn btn-sm btn-warning" data-action="next" aria-label="Next slide"><i class="bi bi-skip-forward-fill" aria-hidden="true"></i></button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="pause" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + pauseButtonClass + '" data-action="pause"><i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + pauseButtonLabel + '</button></form><form method="post" action="/clients/' + encodeURIComponent(client.screen_slug) + '/commands" class="d-inline-block m-0" data-async-command><input type="hidden" name="command" value="blackout" /><input type="hidden" name="blackout" value="' + blackoutCommandValue + '" /><input type="hidden" name="connectionId" value="' + escapeHtml(client.id) + '" /><button type="submit" class="' + blackoutButtonClass + '" data-action="blackout"><i class="bi ' + blackoutButtonIcon + ' me-1" aria-hidden="true"></i>' + blackoutButtonLabel + '</button></form></div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateClientActionCell(cell, client) {
|
function updateClientActionCell(cell, client) {
|
||||||
@@ -107,7 +35,7 @@
|
|||||||
|
|
||||||
var paused = Boolean(client.paused);
|
var paused = Boolean(client.paused);
|
||||||
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
setButtonVariant(pauseButton, ['btn-secondary', 'btn-outline-primary'], 'btn-info');
|
||||||
pauseButton.innerHTML = '<i class="bi bi-' + (paused ? 'play-fill' : 'pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
pauseButton.innerHTML = '<i class="bi ' + (paused ? 'bi-play-fill' : 'bi-pause-fill') + ' me-1" aria-hidden="true"></i>' + (paused ? 'Resume' : 'Pause');
|
||||||
|
|
||||||
var pauseForm = pauseButton.form;
|
var pauseForm = pauseButton.form;
|
||||||
if (pauseForm) {
|
if (pauseForm) {
|
||||||
@@ -252,18 +180,50 @@
|
|||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderScreenRow(screen) {
|
function renderScreenTile(screen) {
|
||||||
var clientCount = Number(screen.player_connection_count || 0);
|
var clientCount = Number(screen.player_connection_count || 0);
|
||||||
|
var playerUrl = String(screen.player_url || '').trim();
|
||||||
|
var connectionLabel = clientCount ? clientCount + ' live' : 'No clients';
|
||||||
|
var connectionStateClass = clientCount ? 'is-live' : 'is-idle';
|
||||||
|
var playlistLabel = screen.playlist_name ? escapeHtml(screen.playlist_name) : 'Unassigned';
|
||||||
|
var connectionsLabel = clientCount ? clientCount + ' connected' : 'No clients connected';
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<tr>',
|
'<article class="dashboard-screen-tile" data-screen-key="' + escapeHtml(screen.id || '') + '">',
|
||||||
'<td data-label="Name">' + escapeHtml(screen.name) + '</td>',
|
'<div class="dashboard-screen-tile-top">',
|
||||||
'<td data-label="Player URL"><a href="' + escapeHtml(screen.player_url || '') + '" target="_blank">' + escapeHtml(screen.player_url || '') + '</a></td>',
|
'<div class="dashboard-screen-tile-text">',
|
||||||
'<td data-label="Playlist">' + escapeHtml(screen.playlist_name || '') + '</td>',
|
'<h4 class="dashboard-screen-name">' + escapeHtml(screen.name || '') + '</h4>',
|
||||||
'<td data-label="Connected clients">' + (clientCount ? '<div class="connection-count" data-screen-connection-count="' + escapeHtml(screen.slug) + '">' + clientCount + ' connected</div>' : '<span class="empty">No clients connected.</span>') + '</td>',
|
'<a class="dashboard-screen-link" href="' + escapeHtml(playerUrl) + '" target="_blank">' + escapeHtml(playerUrl) + '</a>',
|
||||||
'</tr>'
|
'</div>',
|
||||||
|
'<span class="dashboard-screen-pill ' + connectionStateClass + '">' + escapeHtml(connectionLabel) + '</span>',
|
||||||
|
'</div>',
|
||||||
|
'<dl class="dashboard-screen-meta">',
|
||||||
|
'<div><dt>Playlist</dt><dd>' + playlistLabel + '</dd></div>',
|
||||||
|
'<div><dt>Connections</dt><dd>' + escapeHtml(connectionsLabel) + '</dd></div>',
|
||||||
|
'</dl>',
|
||||||
|
'</article>'
|
||||||
].join('');
|
].join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function compareScreensByConnectedClients(left, right) {
|
||||||
|
var leftCount = Number(left && left.player_connection_count || 0);
|
||||||
|
var rightCount = Number(right && right.player_connection_count || 0);
|
||||||
|
|
||||||
|
if (leftCount !== rightCount) {
|
||||||
|
return rightCount - leftCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
var leftName = String(left && left.name || '').trim();
|
||||||
|
var rightName = String(right && right.name || '').trim();
|
||||||
|
var nameCompare = leftName.localeCompare(rightName, undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
|
||||||
|
if (nameCompare !== 0) {
|
||||||
|
return nameCompare;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(left && left.slug || '').localeCompare(String(right && right.slug || ''), undefined, { sensitivity: 'base', numeric: true });
|
||||||
|
}
|
||||||
|
|
||||||
function updateStats(state) {
|
function updateStats(state) {
|
||||||
var clientCount = document.getElementById('dashboard-client-count');
|
var clientCount = document.getElementById('dashboard-client-count');
|
||||||
var screenCount = document.getElementById('dashboard-screen-count');
|
var screenCount = document.getElementById('dashboard-screen-count');
|
||||||
@@ -352,36 +312,52 @@
|
|||||||
tbody.removeChild(tbody.lastElementChild);
|
tbody.removeChild(tbody.lastElementChild);
|
||||||
}
|
}
|
||||||
|
|
||||||
window.applyTableSort(document.getElementById('dashboard-clients-table'));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateScreenTable(state) {
|
function updateScreenGrid(state) {
|
||||||
var table = document.getElementById('dashboard-screens-table');
|
var grid = document.getElementById('dashboard-screens-grid');
|
||||||
if (!table || !Array.isArray(state.screens)) {
|
if (!grid || !Array.isArray(state.screens)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var tbody = table.tBodies && table.tBodies[0] ? table.tBodies[0] : null;
|
var screens = state.screens.slice().sort(compareScreensByConnectedClients);
|
||||||
if (!tbody) {
|
|
||||||
|
if (!screens.length) {
|
||||||
|
grid.innerHTML = '<div class="empty dashboard-screen-empty">No screens yet.</div>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!state.screens.length) {
|
grid.innerHTML = screens.map(renderScreenTile).join('');
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="empty">No screens yet.</td></tr>';
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
tbody.innerHTML = state.screens.map(renderScreenRow).join('');
|
|
||||||
window.applyTableSort(table);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateDashboardQuickActions(state) {
|
function updateDashboardQuickActions(state) {
|
||||||
|
var pauseButton = document.getElementById('dashboard-pause-all-button');
|
||||||
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
var blackoutButton = document.getElementById('dashboard-blackout-all-button');
|
||||||
if (!blackoutButton || !state || !Array.isArray(state.clients)) {
|
if ((!pauseButton && !blackoutButton) || !state || !Array.isArray(state.clients)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
var hasClients = state.clients.length > 0;
|
var hasClients = state.clients.length > 0;
|
||||||
|
var allPaused = hasClients && state.clients.every(function (client) {
|
||||||
|
return Boolean(client && client.paused);
|
||||||
|
});
|
||||||
var allBlackout = hasClients && state.clients.every(function (client) {
|
var allBlackout = hasClients && state.clients.every(function (client) {
|
||||||
return Boolean(client && client.blackout);
|
return Boolean(client && client.blackout);
|
||||||
});
|
});
|
||||||
|
if (pauseButton) {
|
||||||
|
var pauseForm = pauseButton.form;
|
||||||
|
var pauseInput = pauseForm ? pauseForm.querySelector('input[name="paused"]') : null;
|
||||||
|
var pauseLabel = allPaused ? 'Resume all clients' : 'Pause all clients';
|
||||||
|
var pauseButtonIcon = allPaused ? 'bi-play-fill' : 'bi-pause-fill';
|
||||||
|
pauseButton.innerHTML = '<i class="bi ' + pauseButtonIcon + ' me-1" aria-hidden="true"></i>' + escapeHtml(pauseLabel);
|
||||||
|
setButtonVariant(pauseButton, ['btn-success', 'btn-secondary', 'btn-outline-secondary', 'btn-outline-dark'], allPaused ? 'btn-success' : 'btn-info');
|
||||||
|
if (pauseInput) {
|
||||||
|
pauseInput.value = allPaused ? 'false' : 'true';
|
||||||
|
}
|
||||||
|
if (pauseForm) {
|
||||||
|
pauseForm.setAttribute('data-confirm-message', allPaused ? 'Resume all connected clients?' : 'Pause all connected clients?');
|
||||||
|
}
|
||||||
|
pauseButton.setAttribute('aria-label', pauseLabel);
|
||||||
|
}
|
||||||
|
|
||||||
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
var label = allBlackout ? 'Restore all clients' : 'Blackout all clients';
|
||||||
var blackoutForm = blackoutButton.form;
|
var blackoutForm = blackoutButton.form;
|
||||||
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
var blackoutInput = blackoutForm ? blackoutForm.querySelector('input[name="blackout"]') : null;
|
||||||
@@ -403,7 +379,7 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
updateStats(state);
|
updateStats(state);
|
||||||
updateScreenTable(state);
|
updateScreenGrid(state);
|
||||||
updateClientTable(state);
|
updateClientTable(state);
|
||||||
updateDashboardQuickActions(state);
|
updateDashboardQuickActions(state);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
(function () {
|
||||||
|
function getOrCreate(modalElement) {
|
||||||
|
if (!modalElement || !window.bootstrap || !window.bootstrap.Modal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return window.bootstrap.Modal.getOrCreateInstance(modalElement);
|
||||||
|
}
|
||||||
|
|
||||||
|
function show(modalElement) {
|
||||||
|
var modal = getOrCreate(modalElement);
|
||||||
|
if (modal) {
|
||||||
|
modal.show();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hide(modalElement) {
|
||||||
|
var modal = getOrCreate(modalElement);
|
||||||
|
if (modal) {
|
||||||
|
modal.hide();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.pulseModal = {
|
||||||
|
getOrCreate: getOrCreate,
|
||||||
|
show: show,
|
||||||
|
hide: hide
|
||||||
|
};
|
||||||
|
}());
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,63 +1,16 @@
|
|||||||
(function () {
|
(function () {
|
||||||
var REFRESH_INTERVAL_MS = 10000;
|
if (window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-background' && window.location.pathname.replace(/\/$/, '') !== '/settings/tasks-scheduled') {
|
||||||
var pathname = window.location.pathname.replace(/\/$/, '');
|
|
||||||
var stateNode = document.getElementById('background-tasks-state');
|
|
||||||
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
|
||||||
|
|
||||||
if (pathname !== '/settings/background-tasks') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
function stripMessageParameter() {
|
try {
|
||||||
try {
|
var url = new URL(window.location.href);
|
||||||
var url = new URL(window.location.href);
|
if (!url.searchParams.has('message')) {
|
||||||
if (!url.searchParams.has('message')) {
|
|
||||||
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 refreshPage() {
|
|
||||||
if (document.visibilityState !== 'visible') {
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
url.searchParams.delete('message');
|
||||||
fetch('/settings/background-tasks/state', {
|
window.history.replaceState({}, document.title, url.pathname + (url.search ? url.search : '') + url.hash);
|
||||||
method: 'GET',
|
} catch (_error) {
|
||||||
headers: {
|
// Ignore URL cleanup failures.
|
||||||
'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();
|
|
||||||
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
|
||||||
}());
|
}());
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,437 @@
|
|||||||
|
(function () {
|
||||||
|
var templateFields = document.getElementById('template-fields');
|
||||||
|
var modal = document.getElementById('slide-image-cropper-modal');
|
||||||
|
var image = document.getElementById('slide-image-cropper-image');
|
||||||
|
var status = document.getElementById('slide-image-cropper-status');
|
||||||
|
|
||||||
|
if (!templateFields || !modal || !image || !status) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var cropper = null;
|
||||||
|
var currentInput = null;
|
||||||
|
var currentFile = null;
|
||||||
|
var currentObjectUrl = '';
|
||||||
|
var flipX = 1;
|
||||||
|
var flipY = 1;
|
||||||
|
var currentAspectRatio = NaN;
|
||||||
|
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+)$/);
|
||||||
|
return match ? match[1] : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getExistingInput(regionId) {
|
||||||
|
if (!regionId) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return document.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatus(message) {
|
||||||
|
status.textContent = String(message || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function showModal() {
|
||||||
|
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
|
||||||
|
window.pulseModal.show(modal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.bootstrap && window.bootstrap.Modal) {
|
||||||
|
window.bootstrap.Modal.getOrCreateInstance(modal).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideModal() {
|
||||||
|
if (window.pulseModal && typeof window.pulseModal.hide === 'function') {
|
||||||
|
window.pulseModal.hide(modal);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (window.bootstrap && window.bootstrap.Modal) {
|
||||||
|
window.bootstrap.Modal.getOrCreateInstance(modal).hide();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function destroyCropper() {
|
||||||
|
if (cropper) {
|
||||||
|
cropper.destroy();
|
||||||
|
cropper = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeObjectUrl() {
|
||||||
|
if (currentObjectUrl) {
|
||||||
|
URL.revokeObjectURL(currentObjectUrl);
|
||||||
|
currentObjectUrl = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetModalState() {
|
||||||
|
destroyCropper();
|
||||||
|
revokeObjectUrl();
|
||||||
|
currentInput = null;
|
||||||
|
currentFile = null;
|
||||||
|
flipX = 1;
|
||||||
|
flipY = 1;
|
||||||
|
image.removeAttribute('src');
|
||||||
|
image.alt = 'Selected image to crop';
|
||||||
|
setStatus('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonState(disabled) {
|
||||||
|
modal.querySelectorAll('[data-slide-image-cropper-action]').forEach(function (button) {
|
||||||
|
button.disabled = Boolean(disabled) && button.getAttribute('data-slide-image-cropper-action') !== 'reset';
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function ratioLabelForValue(value) {
|
||||||
|
if (value === 'free') {
|
||||||
|
return 'free';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'region') {
|
||||||
|
return 'region';
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(value || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseAspectRatio(value) {
|
||||||
|
var parts = String(value || '').trim().split(/[:/]/);
|
||||||
|
var width = Number(parts[0]);
|
||||||
|
var height = Number(parts[1]);
|
||||||
|
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return NaN;
|
||||||
|
}
|
||||||
|
|
||||||
|
return width / height;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setActiveRatioButton(value) {
|
||||||
|
var targetValue = ratioLabelForValue(value);
|
||||||
|
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"]').forEach(function (button) {
|
||||||
|
var buttonValue = ratioLabelForValue(button.getAttribute('data-slide-image-cropper-ratio'));
|
||||||
|
var isActive = buttonValue === targetValue;
|
||||||
|
button.classList.toggle('active', isActive);
|
||||||
|
button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyAspectRatio(value) {
|
||||||
|
if (!cropper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentAspectRatio = value;
|
||||||
|
if (value === 'free') {
|
||||||
|
cropper.setAspectRatio(NaN);
|
||||||
|
setActiveRatioButton('free');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (value === 'region') {
|
||||||
|
if (!Number.isFinite(currentRegionAspectRatio) || currentRegionAspectRatio <= 0) {
|
||||||
|
cropper.setAspectRatio(NaN);
|
||||||
|
setActiveRatioButton('free');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cropper.setAspectRatio(currentRegionAspectRatio);
|
||||||
|
setActiveRatioButton('region');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var parts = String(value || '').split(':');
|
||||||
|
var width = Number(parts[0]);
|
||||||
|
var height = Number(parts[1]);
|
||||||
|
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cropper.setAspectRatio(width / height);
|
||||||
|
setActiveRatioButton(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function initCropper() {
|
||||||
|
if (!currentInput || !currentFile || !window.Cropper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
destroyCropper();
|
||||||
|
cropper = new window.Cropper(image, {
|
||||||
|
aspectRatio: NaN,
|
||||||
|
autoCropArea: 1,
|
||||||
|
background: false,
|
||||||
|
dragMode: 'move',
|
||||||
|
initialAspectRatio: NaN,
|
||||||
|
movable: true,
|
||||||
|
responsive: true,
|
||||||
|
rotatable: true,
|
||||||
|
scalable: true,
|
||||||
|
viewMode: 1,
|
||||||
|
zoomOnTouch: true,
|
||||||
|
zoomOnWheel: true,
|
||||||
|
ready: function () {
|
||||||
|
if (cropper && cropper.container) {
|
||||||
|
cropper.container.style.width = '100%';
|
||||||
|
cropper.container.style.height = '560px';
|
||||||
|
cropper.container.style.maxHeight = '70vh';
|
||||||
|
}
|
||||||
|
|
||||||
|
var containerData = cropper.getContainerData();
|
||||||
|
var imageData = cropper.getImageData();
|
||||||
|
var fitRatio = Math.min(
|
||||||
|
Number(containerData.width || 0) / Number(imageData.naturalWidth || 1),
|
||||||
|
Number(containerData.height || 0) / Number(imageData.naturalHeight || 1),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
if (Number.isFinite(fitRatio) && fitRatio > 0) {
|
||||||
|
cropper.zoomTo(fitRatio);
|
||||||
|
} else {
|
||||||
|
cropper.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
applyAspectRatio(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
setButtonState(false);
|
||||||
|
setActiveRatioButton(currentAspectRatio === undefined ? 'free' : currentAspectRatio);
|
||||||
|
setStatus('Use the toolbar to crop, rotate, or flip the image before applying it.');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEditor(input, file) {
|
||||||
|
currentInput = input;
|
||||||
|
currentFile = file;
|
||||||
|
flipX = 1;
|
||||||
|
flipY = 1;
|
||||||
|
currentAspectRatio = 'free';
|
||||||
|
currentRegionAspectRatio = parseAspectRatio(input && input.dataset && input.dataset.slideImageCropperRegionRatio);
|
||||||
|
currentRegionAspectRatioLabel = String(input && input.dataset && input.dataset.slideImageCropperRegionRatioLabel || 'Region').trim() || 'Region';
|
||||||
|
setButtonState(true);
|
||||||
|
setStatus('Loading image editor...');
|
||||||
|
|
||||||
|
modal.querySelectorAll('[data-slide-image-cropper-action="ratio"][data-slide-image-cropper-ratio="region"]').forEach(function (button) {
|
||||||
|
button.title = currentRegionAspectRatioLabel ? 'Region ratio ' + currentRegionAspectRatioLabel : 'Region ratio';
|
||||||
|
});
|
||||||
|
|
||||||
|
revokeObjectUrl();
|
||||||
|
currentObjectUrl = URL.createObjectURL(file);
|
||||||
|
image.alt = file.name || 'Selected image to crop';
|
||||||
|
image.src = currentObjectUrl;
|
||||||
|
|
||||||
|
if (!listenersAttached) {
|
||||||
|
listenersAttached = true;
|
||||||
|
modal.addEventListener('shown.bs.modal', function () {
|
||||||
|
if (currentInput && currentFile) {
|
||||||
|
initCropper();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
modal.addEventListener('hidden.bs.modal', function () {
|
||||||
|
resetModalState();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
showModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setInputFile(input, file) {
|
||||||
|
var dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
var input = currentInput;
|
||||||
|
var existing = getExistingInput(getRegionId(input));
|
||||||
|
|
||||||
|
input.dataset.cropperBypass = '1';
|
||||||
|
setInputFile(input, file);
|
||||||
|
if (existing) {
|
||||||
|
existing.value = '';
|
||||||
|
}
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
window.setTimeout(function () {
|
||||||
|
delete input.dataset.cropperBypass;
|
||||||
|
}, 0);
|
||||||
|
hideModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function commitCrop() {
|
||||||
|
if (!currentInput || !currentFile) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cropper) {
|
||||||
|
finalizeCropFile(currentFile);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setButtonState(true);
|
||||||
|
setStatus('Creating the cropped image...');
|
||||||
|
|
||||||
|
var canvas = cropper.getCroppedCanvas({
|
||||||
|
fillColor: 'transparent',
|
||||||
|
imageSmoothingEnabled: true,
|
||||||
|
imageSmoothingQuality: 'high'
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!canvas) {
|
||||||
|
setStatus('Unable to create the cropped image.');
|
||||||
|
setButtonState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var outputType = /^image\/(jpeg|jpg|webp|png)$/i.test(currentFile.type || '') ? currentFile.type : 'image/png';
|
||||||
|
canvas.toBlob(function (blob) {
|
||||||
|
if (!blob) {
|
||||||
|
setStatus('Unable to create the cropped image.');
|
||||||
|
setButtonState(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var finalFile = new File([blob], currentFile.name || 'slide-region-image.png', {
|
||||||
|
lastModified: Date.now(),
|
||||||
|
type: blob.type || outputType
|
||||||
|
});
|
||||||
|
|
||||||
|
finalizeCropFile(finalFile);
|
||||||
|
}, outputType, outputType.indexOf('jpeg') !== -1 ? 0.92 : undefined);
|
||||||
|
}
|
||||||
|
|
||||||
|
function rotateImage(direction) {
|
||||||
|
if (cropper) {
|
||||||
|
cropper.rotate(direction);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function flipImage(axis) {
|
||||||
|
if (!cropper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axis === 'x') {
|
||||||
|
flipX *= -1;
|
||||||
|
cropper.scaleX(flipX);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
flipY *= -1;
|
||||||
|
cropper.scaleY(flipY);
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetImage() {
|
||||||
|
if (!cropper) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
cropper.reset();
|
||||||
|
flipX = 1;
|
||||||
|
flipY = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
templateFields.addEventListener('change', function (event) {
|
||||||
|
var input = event.target;
|
||||||
|
if (!input || !input.matches || !input.matches('input[type="file"][name^="region_image_"]')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dataset.cropperBypass === '1') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var file = input.files && input.files[0];
|
||||||
|
if (!file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(file.size || 0) > uploadMaxBytes) {
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
event.stopPropagation();
|
||||||
|
rejectOversizeFile(input);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!/^image\//i.test(file.type || '')) {
|
||||||
|
input.value = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.stopImmediatePropagation();
|
||||||
|
event.stopPropagation();
|
||||||
|
input.value = '';
|
||||||
|
|
||||||
|
if (!window.Cropper) {
|
||||||
|
currentInput = input;
|
||||||
|
currentFile = file;
|
||||||
|
commitCrop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
openEditor(input, file);
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
modal.addEventListener('click', function (event) {
|
||||||
|
var button = event.target && event.target.closest ? event.target.closest('[data-slide-image-cropper-action]') : null;
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var action = button.getAttribute('data-slide-image-cropper-action');
|
||||||
|
if (action === 'apply') {
|
||||||
|
commitCrop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'ratio') {
|
||||||
|
applyAspectRatio(button.getAttribute('data-slide-image-cropper-ratio') || 'free');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'rotate-left') {
|
||||||
|
rotateImage(-90);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'rotate-right') {
|
||||||
|
rotateImage(90);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'flip-horizontal') {
|
||||||
|
flipImage('x');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'flip-vertical') {
|
||||||
|
flipImage('y');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (action === 'reset') {
|
||||||
|
resetImage();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -1,238 +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 (/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) {
|
function getDefaultRegionSize(regionType, lockRatio) {
|
||||||
var ratio = parseLockRatio(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) {
|
||||||
if (ratio.ratio >= 1) {
|
if (ratio.ratio >= 1) {
|
||||||
|
|||||||
@@ -36,10 +36,26 @@
|
|||||||
var regionCardTemplate = document.getElementById('region-card-template');
|
var regionCardTemplate = document.getElementById('region-card-template');
|
||||||
var regionsJsonInput = document.getElementById('regions-json');
|
var regionsJsonInput = document.getElementById('regions-json');
|
||||||
var templateForm = document.getElementById('template-form');
|
var templateForm = document.getElementById('template-form');
|
||||||
|
var templateRegionUsageElement = document.getElementById('template-region-usage');
|
||||||
|
var regionUsageSet = new Set();
|
||||||
var draft = null;
|
var draft = null;
|
||||||
var selectedIndex = -1;
|
var selectedIndex = -1;
|
||||||
var overlayRenderFrame = 0;
|
var overlayRenderFrame = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
|
||||||
|
if (Array.isArray(regionUsageData)) {
|
||||||
|
regionUsageData.forEach(function (regionKey) {
|
||||||
|
var normalized = String(regionKey || '').trim();
|
||||||
|
if (normalized) {
|
||||||
|
regionUsageSet.add(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
regionUsageSet = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||||
}
|
}
|
||||||
@@ -312,7 +328,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRegionChipLabel(regionType) {
|
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) {
|
function populateRegionCard(card, region) {
|
||||||
@@ -334,7 +350,7 @@
|
|||||||
nameInput.value = region.region_key || region.label || '';
|
nameInput.value = region.region_key || region.label || '';
|
||||||
}
|
}
|
||||||
if (fontFamilyInput) {
|
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) {
|
if (regionTypeInput) {
|
||||||
regionTypeInput.value = region.region_type || 'text';
|
regionTypeInput.value = region.region_type || 'text';
|
||||||
@@ -357,6 +373,21 @@
|
|||||||
updateRegionLockBadge(card);
|
updateRegionLockBadge(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRegionRemovalState(card) {
|
||||||
|
var removeButton = card.querySelector('[data-region-remove-button]');
|
||||||
|
if (!removeButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
|
||||||
|
var regionDeletionLocked = regionUsageSet.has(regionKey);
|
||||||
|
removeButton.disabled = regionDeletionLocked;
|
||||||
|
if (regionDeletionLocked) {
|
||||||
|
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
|
||||||
|
} else {
|
||||||
|
removeButton.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateRegionLabel(card) {
|
function updateRegionLabel(card) {
|
||||||
var label = getRegionName(card) || 'Region';
|
var label = getRegionName(card) || 'Region';
|
||||||
var cards = getCards();
|
var cards = getCards();
|
||||||
@@ -379,6 +410,8 @@
|
|||||||
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
||||||
}
|
}
|
||||||
populateRegionCard(card, region);
|
populateRegionCard(card, region);
|
||||||
|
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
|
||||||
|
setRegionRemovalState(card);
|
||||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||||
@@ -415,12 +448,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
card.addEventListener('click', function (event) {
|
card.addEventListener('click', function (event) {
|
||||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelected(getCards().indexOf(card));
|
setSelected(getCards().indexOf(card));
|
||||||
});
|
});
|
||||||
card.querySelector('.remove-region').addEventListener('click', function (event) {
|
card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
card.remove();
|
card.remove();
|
||||||
if (!getCards().length) {
|
if (!getCards().length) {
|
||||||
@@ -523,15 +556,17 @@
|
|||||||
if (hint) {
|
if (hint) {
|
||||||
hint.remove();
|
hint.remove();
|
||||||
}
|
}
|
||||||
regionList.appendChild(makeRegionCard(region));
|
var card = makeRegionCard(region);
|
||||||
|
setRegionRemovalState(card);
|
||||||
|
regionList.appendChild(card);
|
||||||
setSelected(getCards().length - 1);
|
setSelected(getCards().length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddRegionModal() {
|
function openAddRegionModal() {
|
||||||
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
|
if (!window.pulseModal) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
|
window.pulseModal.show(regionAddModal);
|
||||||
}
|
}
|
||||||
|
|
||||||
function createDefaultRegion(type) {
|
function createDefaultRegion(type) {
|
||||||
@@ -600,8 +635,12 @@
|
|||||||
var currentPoint = toCanvasPoint(moveEvent);
|
var currentPoint = toCanvasPoint(moveEvent);
|
||||||
var dx = currentPoint.x - startPoint.x;
|
var dx = currentPoint.x - startPoint.x;
|
||||||
var dy = currentPoint.y - startPoint.y;
|
var dy = currentPoint.y - startPoint.y;
|
||||||
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
|
var size = getCanvasSize();
|
||||||
writeCard(cardAt(index), { x: next.x, y: next.y });
|
var maxX = Math.max(0, size.width - startRegion.width);
|
||||||
|
var maxY = Math.max(0, size.height - startRegion.height);
|
||||||
|
var nextX = clamp(startRegion.x + dx, 0, maxX);
|
||||||
|
var nextY = clamp(startRegion.y + dy, 0, maxY);
|
||||||
|
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
|
||||||
requestOverlayRender();
|
requestOverlayRender();
|
||||||
}
|
}
|
||||||
function upHandler() {
|
function upHandler() {
|
||||||
@@ -617,6 +656,24 @@
|
|||||||
var startRegion = readCard(cardAt(index));
|
var startRegion = readCard(cardAt(index));
|
||||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||||
|
var canvasSize = getCanvasSize();
|
||||||
|
var minSize = 12;
|
||||||
|
|
||||||
|
function clampResizeDelta(dx, dy) {
|
||||||
|
if (dir.indexOf('w') !== -1) {
|
||||||
|
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
|
||||||
|
}
|
||||||
|
if (dir.indexOf('e') !== -1) {
|
||||||
|
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
|
||||||
|
}
|
||||||
|
if (dir.indexOf('n') !== -1) {
|
||||||
|
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
|
||||||
|
}
|
||||||
|
if (dir.indexOf('s') !== -1) {
|
||||||
|
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
|
||||||
|
}
|
||||||
|
return { dx: dx, dy: dy };
|
||||||
|
}
|
||||||
|
|
||||||
function fitFromWidth(width) {
|
function fitFromWidth(width) {
|
||||||
var nextWidth = Math.max(12, Math.round(width));
|
var nextWidth = Math.max(12, Math.round(width));
|
||||||
@@ -638,6 +695,9 @@
|
|||||||
var currentPoint = toCanvasPoint(moveEvent);
|
var currentPoint = toCanvasPoint(moveEvent);
|
||||||
var dx = currentPoint.x - startPoint.x;
|
var dx = currentPoint.x - startPoint.x;
|
||||||
var dy = currentPoint.y - startPoint.y;
|
var dy = currentPoint.y - startPoint.y;
|
||||||
|
var constrainedDelta = clampResizeDelta(dx, dy);
|
||||||
|
dx = constrainedDelta.dx;
|
||||||
|
dy = constrainedDelta.dy;
|
||||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
||||||
if (aspect) {
|
if (aspect) {
|
||||||
if (dir === 'e') {
|
if (dir === 'e') {
|
||||||
@@ -701,8 +761,8 @@
|
|||||||
button.addEventListener('click', function () {
|
button.addEventListener('click', function () {
|
||||||
var regionType = button.getAttribute('data-add-region-type');
|
var regionType = button.getAttribute('data-add-region-type');
|
||||||
addRegion(createDefaultRegion(regionType));
|
addRegion(createDefaultRegion(regionType));
|
||||||
if (window.bootstrap && window.bootstrap.Modal) {
|
if (window.pulseModal) {
|
||||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
|
window.pulseModal.hide(regionAddModal);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -748,7 +808,6 @@
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSelected(-1);
|
setSelected(-1);
|
||||||
startDraw(event);
|
|
||||||
});
|
});
|
||||||
if (templateForm) {
|
if (templateForm) {
|
||||||
templateForm.addEventListener('formdata', function (event) {
|
templateForm.addEventListener('formdata', function (event) {
|
||||||
|
|||||||
@@ -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();
|
|
||||||
}());
|
|
||||||
@@ -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) {
|
module.exports = function registerAdminContentRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
@@ -8,20 +11,166 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
|
||||||
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
|
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
|
||||||
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
|
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
|
||||||
|
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
|
||||||
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
|
||||||
const getAuditUserId = deps.getAuditUserId;
|
const getAuditUserId = deps.getAuditUserId;
|
||||||
const redirectAfterSave = deps.redirectAfterSave;
|
const redirectAfterSave = deps.redirectAfterSave;
|
||||||
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
const notifyPlayerScreens = deps.notifyPlayerScreens;
|
||||||
const broadcastDashboardState = deps.broadcastDashboardState;
|
const broadcastDashboardState = deps.broadcastDashboardState;
|
||||||
|
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||||
const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage;
|
const getSlideDeleteBlockMessage = deps.getSlideDeleteBlockMessage;
|
||||||
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
|
||||||
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
const hasAnyPermission = deps.hasAnyPermission;
|
||||||
|
const { buildPagination } = require('../../lib/pagination');
|
||||||
|
|
||||||
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') {
|
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 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.');
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
|
||||||
|
return backgroundTaskQueue.enqueueTask({
|
||||||
|
key: 'slide-thumbnail:' + Number(slideId),
|
||||||
|
title: 'Refresh slide thumbnail',
|
||||||
|
category: 'slides',
|
||||||
|
taskType: 'slide-thumbnail-refresh',
|
||||||
|
payload: {
|
||||||
|
slideId: Number(slideId),
|
||||||
|
previousThumbnailPath: String(previousThumbnailPath || '').trim()
|
||||||
|
},
|
||||||
|
persist: true
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function canvasSizeExists(width, height, ignoreId) {
|
async function canvasSizeExists(width, height, ignoreId) {
|
||||||
const params = [width, height];
|
const params = [width, height];
|
||||||
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
|
let query = 'SELECT COUNT(*) AS count FROM canvas_sizes WHERE width = ? AND height = ?';
|
||||||
@@ -58,8 +207,15 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchAdminData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -119,8 +275,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) {
|
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
|
await validateUploadedFiles(req.files || []);
|
||||||
const payload = await common.buildSlidePayload(pool, req, null);
|
const payload = await common.buildSlidePayload(pool, req, null);
|
||||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
||||||
return res.status(400).send('A slide with that title already exists.');
|
return res.status(400).send('A slide with that title already exists.');
|
||||||
@@ -136,6 +326,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
localUploadDir: deps.uploadDir,
|
localUploadDir: deps.uploadDir,
|
||||||
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
nextUploadRefs: collectUploadReferencesFromPayload(payload)
|
||||||
});
|
});
|
||||||
|
queueSlideThumbnailRefresh(result.insertId, null).catch(function (error) {
|
||||||
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||||
|
});
|
||||||
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
redirectAfterSave(req, res, '/slides/' + result.insertId + '/edit', {
|
||||||
closeUrl: '/slides',
|
closeUrl: '/slides',
|
||||||
newUrl: '/slides/new',
|
newUrl: '/slides/new',
|
||||||
@@ -148,6 +341,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
|
|
||||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
|
await validateUploadedFiles(req.files || []);
|
||||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||||
if (!slide) {
|
if (!slide) {
|
||||||
return res.status(404).send('Slide not found');
|
return res.status(404).send('Slide not found');
|
||||||
@@ -176,6 +370,9 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
screenSlideCounts: screenSlideCounts
|
screenSlideCounts: screenSlideCounts
|
||||||
});
|
});
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
|
queueSlideThumbnailRefresh(slide.id, slide.thumbnail_path).catch(function (error) {
|
||||||
|
console.warn('Unable to queue slide thumbnail refresh:', error);
|
||||||
|
});
|
||||||
redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', {
|
redirectAfterSave(req, res, '/slides/' + slide.id + '/edit', {
|
||||||
closeUrl: '/slides',
|
closeUrl: '/slides',
|
||||||
newUrl: '/slides/new',
|
newUrl: '/slides/new',
|
||||||
@@ -218,8 +415,15 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchTemplatesData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -274,6 +478,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
if (!template) {
|
if (!template) {
|
||||||
return res.status(404).send('Template not found');
|
return res.status(404).send('Template not found');
|
||||||
}
|
}
|
||||||
|
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||||
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -287,11 +492,16 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
if (!template) {
|
if (!template) {
|
||||||
return res.status(404).send('Template not found');
|
return res.status(404).send('Template not found');
|
||||||
}
|
}
|
||||||
|
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||||
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
||||||
const payload = await common.buildTemplatePayload(pool, req, template);
|
const payload = await common.buildTemplatePayload(pool, req, template);
|
||||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||||
}
|
}
|
||||||
|
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
|
||||||
|
if (regionDeleteBlockMessage) {
|
||||||
|
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent(regionDeleteBlockMessage));
|
||||||
|
}
|
||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
@@ -355,8 +565,15 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchAdminData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { 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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,9 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
|||||||
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
|
const fetchRssFeedItems = deps.fetchRssFeedItems || (common && common.fetchRssFeedItems) || null;
|
||||||
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
|
const replaceRssFeedItems = deps.replaceRssFeedItems || (common && common.replaceRssFeedItems) || null;
|
||||||
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
|
const fetchApiSourceResponse = common && common.fetchApiSourceResponse ? common.fetchApiSourceResponse : null;
|
||||||
|
const { buildPagination } = require('../../lib/pagination');
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
function toIsoTimestamp(value) {
|
function toIsoTimestamp(value) {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -195,8 +198,24 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
app.get('/data-sources/api-sources', requirePermission('api-sources.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchApiSourcesData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderApiSourcesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser, formatDashboardDate));
|
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'
|
||||||
|
? (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every second' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} seconds`)
|
||||||
|
: (Math.max(1, Number(apiSource.update_interval_value) || 0) === 1 ? 'Every minute' : `Every ${Math.max(1, Number(apiSource.update_interval_value) || 0)} minutes`),
|
||||||
|
lastPullLabel: apiSource.last_pulled_at ? formatDashboardDate(apiSource.last_pulled_at) : 'Never',
|
||||||
|
lastPulledAtValue: apiSource.last_pulled_at ? new Date(apiSource.last_pulled_at).toISOString() : ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
res.send(pages.renderApiSourcesPage({
|
||||||
|
apiSources: apiSources,
|
||||||
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -364,8 +383,15 @@ module.exports = function registerAdminDataSourceRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
app.get('/data-sources/rss-feeds', requirePermission('rss-feeds.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchRssFeedsData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderRssFeedsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { 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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,12 +36,15 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
const blackoutValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'blackout')
|
||||||
? req.body.blackout
|
? req.body.blackout
|
||||||
: req.query.blackout;
|
: req.query.blackout;
|
||||||
|
const pausedValue = req.body && Object.prototype.hasOwnProperty.call(req.body, 'paused')
|
||||||
|
? req.body.paused
|
||||||
|
: req.query.paused;
|
||||||
|
|
||||||
if (!command) {
|
if (!command) {
|
||||||
return res.status(400).json({ error: 'Command is required' });
|
return res.status(400).json({ error: 'Command is required' });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (command !== 'reload' && command !== 'blackout') {
|
if (command !== 'reload' && command !== 'blackout' && command !== 'pause') {
|
||||||
return res.status(400).json({ error: 'Unsupported command' });
|
return res.status(400).json({ error: 'Unsupported command' });
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -56,7 +59,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
command: 'blackout',
|
command: 'blackout',
|
||||||
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
|
blackout: blackoutValue === true || blackoutValue === 'true' || blackoutValue === '1' ? true : false
|
||||||
}
|
}
|
||||||
: 'reload';
|
: command === 'pause'
|
||||||
|
? {
|
||||||
|
command: 'pause',
|
||||||
|
paused: pausedValue === true || pausedValue === 'true' || pausedValue === '1' ? true : false
|
||||||
|
}
|
||||||
|
: 'reload';
|
||||||
|
|
||||||
const sentCount = await notifyPlayerScreens(slugs, payload);
|
const sentCount = await notifyPlayerScreens(slugs, payload);
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
@@ -65,7 +73,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
ok: true,
|
ok: true,
|
||||||
command: command,
|
command: command,
|
||||||
sent: sentCount,
|
sent: sentCount,
|
||||||
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined
|
blackout: command === 'blackout' ? Boolean(payload.blackout) : undefined,
|
||||||
|
paused: command === 'pause' ? Boolean(payload.paused) : undefined
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
@@ -76,6 +85,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
try {
|
try {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||||
|
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).send('Playlist name is required.');
|
return res.status(400).send('Playlist name is required.');
|
||||||
}
|
}
|
||||||
@@ -83,7 +93,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
return res.status(400).send('A playlist with that name already exists.');
|
return res.status(400).send('A playlist with that name already exists.');
|
||||||
}
|
}
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, created_by, modified_by) VALUES (?, ?, ?, ?)', [name, fadeBetweenSlides, actorId, actorId]);
|
const [result] = await pool.query('INSERT INTO playlists (name, fade_between_slides, skip_unavailable_rtmp, created_by, modified_by) VALUES (?, ?, ?, ?, ?)', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, actorId]);
|
||||||
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
redirectAfterSave(req, res, '/playlists?edit=' + result.insertId, {
|
||||||
closeUrl: '/playlists',
|
closeUrl: '/playlists',
|
||||||
newUrl: '/playlists/new',
|
newUrl: '/playlists/new',
|
||||||
@@ -99,6 +109,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
try {
|
try {
|
||||||
const name = String(req.body.name || '').trim();
|
const name = String(req.body.name || '').trim();
|
||||||
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
const fadeBetweenSlides = req.body.fade_between_slides ? 1 : 0;
|
||||||
|
const skipUnavailableRtmp = req.body.skip_unavailable_rtmp ? 1 : 0;
|
||||||
if (!name) {
|
if (!name) {
|
||||||
return res.status(400).send('Playlist name is required.');
|
return res.status(400).send('Playlist name is required.');
|
||||||
}
|
}
|
||||||
@@ -114,6 +125,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
|
|
||||||
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
||||||
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
|
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 scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
|
||||||
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
||||||
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
||||||
@@ -124,6 +136,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
if (durations.length && durations.length !== slideIds.length) {
|
if (durations.length && durations.length !== slideIds.length) {
|
||||||
return res.status(400).send('Playlist slide data is invalid.');
|
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) {
|
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
|
||||||
return res.status(400).send('Playlist schedule data is invalid.');
|
return res.status(400).send('Playlist schedule data is invalid.');
|
||||||
}
|
}
|
||||||
@@ -141,7 +156,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
seenSlideIds.add(slideId);
|
seenSlideIds.add(slideId);
|
||||||
|
|
||||||
const durationRaw = Number(durations[i]);
|
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]);
|
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
|
||||||
|
|
||||||
let scheduleStartDatetime = null;
|
let scheduleStartDatetime = null;
|
||||||
@@ -186,6 +202,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
slideId,
|
slideId,
|
||||||
position: i,
|
position: i,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
|
useVideoDuration,
|
||||||
scheduleMode,
|
scheduleMode,
|
||||||
scheduleStartDatetime,
|
scheduleStartDatetime,
|
||||||
scheduleEndDatetime,
|
scheduleEndDatetime,
|
||||||
@@ -220,17 +237,18 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
|
||||||
await connection.beginTransaction();
|
await connection.beginTransaction();
|
||||||
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, actorId, playlist.id]);
|
await connection.query('UPDATE playlists SET name = ?, fade_between_slides = ?, skip_unavailable_rtmp = ?, modified_by = ? WHERE id = ?', [name, fadeBetweenSlides, skipUnavailableRtmp, actorId, playlist.id]);
|
||||||
await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
await connection.query('DELETE FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||||
const item = normalizedSlides[i];
|
const item = normalizedSlides[i];
|
||||||
await connection.query(
|
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,
|
playlist.id,
|
||||||
item.slideId,
|
item.slideId,
|
||||||
item.position,
|
item.position,
|
||||||
item.durationSeconds,
|
item.durationSeconds,
|
||||||
|
item.useVideoDuration,
|
||||||
item.scheduleMode,
|
item.scheduleMode,
|
||||||
item.scheduleStartDatetime,
|
item.scheduleStartDatetime,
|
||||||
item.scheduleEndDatetime,
|
item.scheduleEndDatetime,
|
||||||
@@ -301,11 +319,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
||||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
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 [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 nextPosition = Number(positionRows[0].max_position) + 1;
|
||||||
const actorId = getAuditUserId(req);
|
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 notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||||
@@ -321,7 +340,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
return res.status(404).send('Playlist not found');
|
return res.status(404).send('Playlist not found');
|
||||||
}
|
}
|
||||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
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 actorId = getAuditUserId(req);
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
|||||||
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
const permissions = Array.isArray(deps.permissions) ? deps.permissions : [];
|
||||||
const readArrayField = deps.readArrayField;
|
const readArrayField = deps.readArrayField;
|
||||||
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
const normalizePermissionKeys = deps.normalizePermissionKeys;
|
||||||
|
const { buildPagination } = require('../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
function slugifyRoleKey(name) {
|
function slugifyRoleKey(name) {
|
||||||
const value = String(name || '').trim().toLowerCase();
|
const value = String(name || '').trim().toLowerCase();
|
||||||
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
const slug = value.replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
||||||
@@ -158,8 +161,15 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
app.get('/rbac', requirePermission('rbac.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const roles = await rbacData.fetchRoles(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderRbacPage({ roles: roles }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'roles', 'Role pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -362,7 +372,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
|
|||||||
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
|
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.'));
|
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|||||||
@@ -7,8 +7,11 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
|||||||
const hashPassword = deps.hashPassword;
|
const hashPassword = deps.hashPassword;
|
||||||
const readArrayField = deps.readArrayField;
|
const readArrayField = deps.readArrayField;
|
||||||
const rbacData = deps.rbacData;
|
const rbacData = deps.rbacData;
|
||||||
|
const { buildPagination } = require('../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
async function fetchRoleOptions() {
|
async function fetchRoleOptions() {
|
||||||
return rbacData.fetchRoles(pool);
|
return rbacData.fetchRoles(pool);
|
||||||
}
|
}
|
||||||
@@ -53,8 +56,12 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
app.get('/users', requirePermission('users.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const users = await rbacData.fetchUsersWithRoles(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
const mappedUsers = users.map(function (user) {
|
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, {
|
return Object.assign({}, user, {
|
||||||
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
isCurrentUser: Number(user.id) === Number(req.currentUser.id),
|
||||||
createdAtLabel: formatDashboardDate(user.created_at),
|
createdAtLabel: formatDashboardDate(user.created_at),
|
||||||
@@ -62,7 +69,10 @@ module.exports = function registerAdminUsersRoutes(app, deps) {
|
|||||||
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
roleNames: String(user.roleNames || '').trim() || 'No roles assigned'
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
res.send(pages.renderUsersPage({ users: mappedUsers }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
res.send(pages.renderUsersPage({
|
||||||
|
users: mappedUsers,
|
||||||
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ module.exports = function renderApiSourcesPage(data, message, currentUser, forma
|
|||||||
active: 'api-sources',
|
active: 'api-sources',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
apiSources: apiSources
|
apiSources: apiSources,
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -27,6 +27,7 @@ module.exports = function renderRssFeedsPage(data, message, currentUser) {
|
|||||||
active: 'rss-feeds',
|
active: 'rss-feeds',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
rssFeeds: rssFeeds
|
rssFeeds: rssFeeds,
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -27,7 +27,8 @@ module.exports = {
|
|||||||
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
renderCanvasSizesPage: require('./canvas-sizes/list'),
|
||||||
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
|
||||||
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
|
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'),
|
renderErrorPage: require('./error'),
|
||||||
renderRbacPage: require('./rbac/list'),
|
renderRbacPage: require('./rbac/list'),
|
||||||
renderRbacAddPage: require('./rbac/add'),
|
renderRbacAddPage: require('./rbac/add'),
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
const { renderView } = require('../../view');
|
const { renderView } = require('../../view');
|
||||||
const { hasAnyPermission } = require('../../../rbac');
|
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 TASKS_PER_PAGE = 10;
|
||||||
|
const QUEUE_PAGE_URL = '/settings/tasks-background';
|
||||||
|
const SCHEDULED_PAGE_URL = '/settings/tasks-scheduled';
|
||||||
|
|
||||||
function formatIntervalLabel(intervalMs) {
|
function formatIntervalLabel(intervalMs) {
|
||||||
const value = Math.max(1, Number(intervalMs) || 0);
|
const value = Math.max(1, Number(intervalMs) || 0);
|
||||||
@@ -16,73 +20,262 @@ function formatIntervalLabel(intervalMs) {
|
|||||||
return `${value} ms`;
|
return `${value} ms`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function parsePageNumber(value) {
|
function normalizeSourceFilter(sourceType, sourceId) {
|
||||||
const pageNumber = Math.floor(Number(value) || 1);
|
const normalizedSourceType = String(sourceType || '').trim();
|
||||||
return Math.max(1, pageNumber);
|
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
||||||
}
|
|
||||||
|
|
||||||
function buildPagination(totalItems, currentPage, pageParam) {
|
if (!normalizedSourceType || !Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) {
|
||||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
return { sourceType: '', sourceId: '' };
|
||||||
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) {
|
|
||||||
pages.push({
|
|
||||||
number: pageNumber,
|
|
||||||
active: pageNumber === safeCurrentPage,
|
|
||||||
url: `?${normalizedPageParam}=${pageNumber}`
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPage: safeCurrentPage,
|
sourceType: normalizedSourceType,
|
||||||
totalPages: totalPages,
|
sourceId: normalizedSourceId
|
||||||
totalItems: totalItems,
|
|
||||||
hasMultiplePages: totalPages > 1,
|
|
||||||
startItem: totalItems === 0 ? 0 : startIndex + 1,
|
|
||||||
endItem: endIndex,
|
|
||||||
hasPrevious: safeCurrentPage > 1,
|
|
||||||
hasNext: safeCurrentPage < totalPages,
|
|
||||||
previousUrl: `?${normalizedPageParam}=${safeCurrentPage - 1}`,
|
|
||||||
nextUrl: `?${normalizedPageParam}=${safeCurrentPage + 1}`,
|
|
||||||
pages: pages,
|
|
||||||
pageSize: TASKS_PER_PAGE,
|
|
||||||
pageParam: normalizedPageParam
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
|
function taskMatchesFilters(task, sourceFilter) {
|
||||||
|
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const metadata = task.metadata || {};
|
||||||
|
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) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
if (normalized === 'queued') {
|
||||||
|
return 'Queued';
|
||||||
|
}
|
||||||
|
if (normalized === 'running') {
|
||||||
|
return 'Running';
|
||||||
|
}
|
||||||
|
if (normalized === 'completed') {
|
||||||
|
return 'Completed';
|
||||||
|
}
|
||||||
|
if (normalized === 'failed') {
|
||||||
|
return 'Failed';
|
||||||
|
}
|
||||||
|
if (normalized === 'canceled') {
|
||||||
|
return 'Canceled';
|
||||||
|
}
|
||||||
|
return normalized.charAt(0).toUpperCase() + normalized.slice(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
|
||||||
|
const metadata = task.metadata || {};
|
||||||
|
const sourceType = String(metadata.sourceType || '').trim();
|
||||||
|
const sourceId = Math.floor(Number(metadata.sourceId) || 0);
|
||||||
|
|
||||||
|
if (!sourceType || !Number.isFinite(sourceId) || sourceId <= 0) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActive = sourceFilter && sourceFilter.sourceType === sourceType && sourceFilter.sourceId === sourceId;
|
||||||
|
const nextQuery = Object.assign({}, queryState, {
|
||||||
|
page: 1,
|
||||||
|
sourceType: isActive ? '' : sourceType,
|
||||||
|
sourceId: isActive ? '' : sourceId
|
||||||
|
});
|
||||||
|
|
||||||
|
return buildQueryString(nextQuery);
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueuePageViewModel(data, message, currentUser) {
|
||||||
const tasks = (data && data.tasks) || [];
|
const tasks = (data && data.tasks) || [];
|
||||||
const recurringTasks = (data && data.recurringTasks) || [];
|
|
||||||
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
|
||||||
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
|
const canManage = hasAnyPermission(currentUser, ['background-tasks.allow']);
|
||||||
const pagination = buildPagination(tasks.length, parsePageNumber(data && data.page), 'page');
|
const sort = String(data && data.sort || '').trim();
|
||||||
const recurringPagination = buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage');
|
const direction = normalizeSortDirection(data && data.direction);
|
||||||
const visibleTasks = tasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
const queryState = {
|
||||||
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
|
page: parsePageNumber(data && data.page),
|
||||||
|
sourceType: '',
|
||||||
|
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) && 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 sortedTasks = sortRows(filteredTasks, function (task) {
|
||||||
|
if (sort === 'task') {
|
||||||
|
return task && task.title;
|
||||||
|
}
|
||||||
|
if (sort === 'category') {
|
||||||
|
return task && task.category;
|
||||||
|
}
|
||||||
|
if (sort === 'status') {
|
||||||
|
return task && task.status;
|
||||||
|
}
|
||||||
|
if (sort === 'queued') {
|
||||||
|
return task && task.createdAt;
|
||||||
|
}
|
||||||
|
if (sort === 'started') {
|
||||||
|
return task && task.startedAt;
|
||||||
|
}
|
||||||
|
if (sort === 'finished') {
|
||||||
|
return task && task.finishedAt;
|
||||||
|
}
|
||||||
|
if (sort === 'source') {
|
||||||
|
return [task && task.metadata && task.metadata.sourceName, task && task.metadata && task.metadata.sourceType, task && task.metadata && task.metadata.sourceId].map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).join(' ');
|
||||||
|
}
|
||||||
|
return task && task.createdAt;
|
||||||
|
}, direction);
|
||||||
|
const pagination = buildPagination(sortedTasks.length, queryState.page, 'page', queryState, TASKS_PER_PAGE, 'tasks', 'Background task pages');
|
||||||
|
const visibleTasks = sortedTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
||||||
|
const statusCards = [
|
||||||
|
{
|
||||||
|
status: 'queued',
|
||||||
|
label: 'Queued',
|
||||||
|
count: summary.counts.queued,
|
||||||
|
variant: 'primary'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'running',
|
||||||
|
label: 'Running',
|
||||||
|
count: summary.counts.running,
|
||||||
|
variant: 'info'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'failed',
|
||||||
|
label: 'Failed',
|
||||||
|
count: summary.counts.failed,
|
||||||
|
variant: 'danger'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
status: 'completed',
|
||||||
|
label: 'Finished',
|
||||||
|
count: summary.counts.completed,
|
||||||
|
variant: 'success'
|
||||||
|
}
|
||||||
|
].map(function (card) {
|
||||||
|
return Object.assign({}, card, {
|
||||||
|
active: false
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
return renderView('settings/background-tasks/index', {
|
const visibleTasksWithSources = visibleTasks.map(function (task) {
|
||||||
|
return Object.assign({}, task, {
|
||||||
|
sourceUrl: buildTaskSourceFilterUrl(queryState, task, sourceFilter),
|
||||||
|
sourceLabel: task.metadata && task.metadata.sourceName ? String(task.metadata.sourceName).trim() : '',
|
||||||
|
sourceTypeLabel: task.metadata && task.metadata.sourceType ? String(task.metadata.sourceType).trim() : '',
|
||||||
|
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
|
||||||
|
});
|
||||||
|
});
|
||||||
|
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
|
||||||
|
|
||||||
|
return renderView('settings/tasks-background/index', {
|
||||||
title: 'Background tasks',
|
title: 'Background tasks',
|
||||||
active: 'background-tasks',
|
active: 'background-tasks',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
scripts: ['js/settings/background-tasks.js'],
|
scripts: ['js/settings/background-tasks.js'],
|
||||||
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
backgroundTasksMenuOpen: true,
|
||||||
tasks: visibleTasks,
|
tasks: visibleTasksWithSources,
|
||||||
recurringTasks: visibleRecurringTasks.map(function (task) {
|
|
||||||
return Object.assign({}, task, {
|
|
||||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
summary: summary,
|
summary: summary,
|
||||||
|
statusCards: statusCards,
|
||||||
|
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||||
|
clearFiltersUrl: QUEUE_PAGE_URL,
|
||||||
|
hasActiveFilters: hasActiveFilters,
|
||||||
pagination: pagination,
|
pagination: pagination,
|
||||||
recurringPagination: recurringPagination,
|
|
||||||
canManage: canManage,
|
canManage: canManage,
|
||||||
canClearFinished: tasks.some(function (task) {
|
canClearFinished: tasks.some(function (task) {
|
||||||
return task.status !== 'queued' && task.status !== 'running';
|
return task.status !== 'queued' && task.status !== 'running';
|
||||||
})
|
})
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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) {
|
module.exports = function registerAdminSettingsRoutes(app, deps) {
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
const backgroundTaskQueue = deps.backgroundTaskQueue;
|
||||||
@@ -25,44 +42,18 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
|
throw new Error('registerAdminSettingsRoutes requires pages and backgroundTaskQueue.');
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildQueueVersion(tasks, recurringTasks, summary) {
|
app.get('/settings/tasks-background', requireSettingsAccess(), function (req, res) {
|
||||||
return [
|
|
||||||
'q:' + Number(summary && summary.counts && summary.counts.queued || 0),
|
|
||||||
'r:' + Number(summary && summary.counts && summary.counts.running || 0),
|
|
||||||
'f:' + Number(summary && summary.counts && summary.counts.failed || 0),
|
|
||||||
'c:' + Number(summary && summary.counts && summary.counts.completed || 0),
|
|
||||||
'a:' + Number(summary && summary.activeCount || 0),
|
|
||||||
't:' + Number(summary && summary.total || 0),
|
|
||||||
'tasks:' + (tasks || []).map(function (task) {
|
|
||||||
return [task.id, task.status, task.startedAt || '', task.finishedAt || '', task.errorMessage || '', task.attempts || 0].join(':');
|
|
||||||
}).join('|'),
|
|
||||||
'recurring:' + (recurringTasks || []).map(function (task) {
|
|
||||||
return [task.key, task.nextRunAt || '', task.lastRunAt || '', task.lastStatus || '', task.lastError || ''].join(':');
|
|
||||||
}).join('|')
|
|
||||||
].join('~');
|
|
||||||
}
|
|
||||||
|
|
||||||
app.get('/settings/background-tasks', requireSettingsAccess(), function (req, res) {
|
|
||||||
const tasks = backgroundTaskQueue.listTasks();
|
const tasks = backgroundTaskQueue.listTasks();
|
||||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||||
const summary = backgroundTaskQueue.getSummary();
|
const summary = backgroundTaskQueue.getSummary();
|
||||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/settings/background-tasks/state', requireSettingsAccess(), function (req, res) {
|
app.get('/settings/tasks-scheduled', requireScheduledSettingsAccess(), function (req, res) {
|
||||||
const tasks = backgroundTaskQueue.listTasks();
|
const tasks = backgroundTaskQueue.listTasks();
|
||||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||||
const summary = backgroundTaskQueue.getSummary();
|
const summary = backgroundTaskQueue.getSummary();
|
||||||
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, page: req.query.page, recurringPage: req.query.recurringPage, search: req.query.search, sort: req.query.sort, direction: req.query.direction }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
|
|
||||||
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, private');
|
|
||||||
res.json({
|
|
||||||
version: version,
|
|
||||||
summary: summary,
|
|
||||||
taskCount: tasks.length,
|
|
||||||
recurringCount: recurringTasks.length
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function requireSettingsManageAccess() {
|
function requireSettingsManageAccess() {
|
||||||
@@ -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();
|
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));
|
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) {
|
app.post('/settings/tasks-background/:id/retry', requireSettingsManageAccess(), function (req, res) {
|
||||||
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
const retriedTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
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.'));
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ module.exports = function renderRbacPage(data, message, currentUser) {
|
|||||||
active: 'rbac',
|
active: 'rbac',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
roles: data.roles || []
|
roles: data.roles || [],
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -6,6 +6,7 @@ module.exports = function renderUsersPage(data, message, currentUser) {
|
|||||||
active: 'users',
|
active: 'users',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
users: data.users || []
|
users: data.users || [],
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -6,7 +6,9 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) {
|
|||||||
name: size.name,
|
name: size.name,
|
||||||
width: size.width,
|
width: size.width,
|
||||||
height: size.height,
|
height: size.height,
|
||||||
templateCount: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length
|
templateCount: size.template_count !== undefined
|
||||||
|
? Number(size.template_count) || 0
|
||||||
|
: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return renderView('canvas-sizes/list', {
|
return renderView('canvas-sizes/list', {
|
||||||
@@ -14,6 +16,7 @@ module.exports = function renderCanvasSizesPage(data, message, currentUser) {
|
|||||||
active: 'canvas-sizes',
|
active: 'canvas-sizes',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
canvasSizes: canvasSizes
|
canvasSizes: canvasSizes,
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ module.exports = function registerCanvasSizeRoutes(app, deps) {
|
|||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchCanvasSizesData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { 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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ module.exports = function renderConnectedClientsPage(data, message, currentUser)
|
|||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
scripts: ['js/dashboard/dashboard-page.js'],
|
scripts: ['js/dashboard/dashboard-page.js'],
|
||||||
clients: data.clients || []
|
clients: data.clients || [],
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -1,13 +1,66 @@
|
|||||||
module.exports = function registerClientsRoutes(app, deps) {
|
module.exports = function registerClientsRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
const common = deps.common;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const buildDashboardState = deps.buildDashboardState;
|
const buildDashboardState = deps.buildDashboardState;
|
||||||
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
|
const { sortRows, createSearchMatcher } = require('../../../lib/list-query');
|
||||||
const requirePermission = deps.requirePermission;
|
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) {
|
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await buildDashboardState(pool);
|
const data = await buildDashboardState(pool);
|
||||||
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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 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 = sortedClients.slice(startIndex, startIndex + LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderConnectedClientsPage({
|
||||||
|
clients: clients,
|
||||||
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,8 @@ module.exports = function renderPlaylistFormPage(message, currentUser) {
|
|||||||
title: 'Create playlist',
|
title: 'Create playlist',
|
||||||
active: 'playlists',
|
active: 'playlists',
|
||||||
playlist: {
|
playlist: {
|
||||||
fade_between_slides: false
|
fade_between_slides: false,
|
||||||
|
skip_unavailable_rtmp: false
|
||||||
},
|
},
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null
|
currentUser: currentUser || null
|
||||||
|
|||||||
@@ -61,6 +61,86 @@ function getCanvasSignature(width, height) {
|
|||||||
return normalizedWidth + 'x' + normalizedHeight;
|
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) {
|
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
|
||||||
const playlistSlides = (data.playlistSlides || [])
|
const playlistSlides = (data.playlistSlides || [])
|
||||||
.filter((item) => item.playlist_id === playlist.id)
|
.filter((item) => item.playlist_id === playlist.id)
|
||||||
@@ -75,6 +155,11 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
|||||||
isFirst: index === 0,
|
isFirst: index === 0,
|
||||||
isLast: index === items.length - 1,
|
isLast: index === items.length - 1,
|
||||||
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
|
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),
|
||||||
scheduleSummary: scheduleSummary(item)
|
scheduleSummary: scheduleSummary(item)
|
||||||
}));
|
}));
|
||||||
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
||||||
@@ -91,7 +176,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
|||||||
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
|
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
|
||||||
}).map((slide) => Object.assign({}, slide, {
|
}).map((slide) => Object.assign({}, slide, {
|
||||||
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
|
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);
|
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
|
||||||
|
|
||||||
@@ -102,6 +192,7 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
|||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
playlist: playlist,
|
playlist: playlist,
|
||||||
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
|
||||||
|
playlistSkipUnavailableRtmp: Boolean(playlist.skip_unavailable_rtmp),
|
||||||
playlistSlides: playlistSlides,
|
playlistSlides: playlistSlides,
|
||||||
slides: data.slides || [],
|
slides: data.slides || [],
|
||||||
hasSlides: (data.slides || []).length > 0,
|
hasSlides: (data.slides || []).length > 0,
|
||||||
@@ -110,6 +201,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
|||||||
selectableSlides: selectableSlides,
|
selectableSlides: selectableSlides,
|
||||||
playlistCanvasSignature: playlistCanvasSignature,
|
playlistCanvasSignature: playlistCanvasSignature,
|
||||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||||
scripts: ['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']
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
const { renderView } = require('../../../view');
|
const { renderView } = require('../../../view');
|
||||||
|
|
||||||
module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
||||||
const playlistSlides = data.playlistSlides || [];
|
|
||||||
const playlists = (data.playlists || []).map((playlist) => {
|
const playlists = (data.playlists || []).map((playlist) => {
|
||||||
const slideCount = playlistSlides.filter((item) => item.playlist_id === playlist.id).length;
|
const slideCount = playlist.slide_count !== undefined
|
||||||
|
? Number(playlist.slide_count) || 0
|
||||||
|
: (data.playlistSlides || []).filter((item) => item.playlist_id === playlist.id).length;
|
||||||
return Object.assign({}, playlist, {
|
return Object.assign({}, playlist, {
|
||||||
slideCount: slideCount,
|
slideCount: slideCount,
|
||||||
slideCountIsOne: slideCount === 1
|
slideCountIsOne: slideCount === 1
|
||||||
@@ -15,7 +16,8 @@ module.exports = function renderPlaylistsPage(data, message, currentUser) {
|
|||||||
active: 'playlists',
|
active: 'playlists',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
playlists: playlists
|
playlists: playlists,
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
|||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||||
@@ -13,15 +16,23 @@ module.exports = function registerPlaylistsRoutes(app, deps) {
|
|||||||
|
|
||||||
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchAdminData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
|
const search = common.getSearchQuery(req);
|
||||||
|
const sort = common.getSortQuery(req);
|
||||||
|
const direction = common.getSortDirectionQuery(req);
|
||||||
if (req.query.edit) {
|
if (req.query.edit) {
|
||||||
|
const data = await common.fetchAdminData(pool);
|
||||||
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
|
||||||
if (!playlist) {
|
if (!playlist) {
|
||||||
return res.status(404).send('Playlist not found');
|
return res.status(404).send('Playlist not found');
|
||||||
}
|
}
|
||||||
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
}
|
}
|
||||||
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'playlists', 'Playlist pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,6 +74,6 @@ module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide,
|
|||||||
...day,
|
...day,
|
||||||
checked: selectedDays.has(day.value)
|
checked: selectedDays.has(day.value)
|
||||||
})),
|
})),
|
||||||
scripts: ['js/playlists/playlist-schedule.js']
|
scripts: ['js/lib/modal.js', 'js/playlists/playlist-schedule.js']
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ module.exports = function renderScreensPage(data, message, currentUser) {
|
|||||||
active: 'screens',
|
active: 'screens',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
screens: data.screens || []
|
screens: data.screens || [],
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const buildDashboardState = deps.buildDashboardState;
|
const buildDashboardState = deps.buildDashboardState;
|
||||||
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
function requireQueryPermission(readPermissionKey, editPermissionKey) {
|
||||||
return function (req, res, next) {
|
return function (req, res, next) {
|
||||||
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
|
||||||
@@ -12,9 +15,38 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function applyConnectionCounts(screens, dashboardScreens) {
|
||||||
|
const countsById = new Map();
|
||||||
|
const countsBySlug = new Map();
|
||||||
|
|
||||||
|
(dashboardScreens || []).forEach(function (screen) {
|
||||||
|
const count = Number(screen && screen.player_connection_count || 0);
|
||||||
|
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
|
||||||
|
const slug = String(screen && screen.slug || '').trim();
|
||||||
|
|
||||||
|
if (id) {
|
||||||
|
countsById.set(id, count);
|
||||||
|
}
|
||||||
|
if (slug) {
|
||||||
|
countsBySlug.set(slug, count);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return (screens || []).map(function (screen) {
|
||||||
|
const slug = String(screen && screen.slug || '').trim();
|
||||||
|
const id = screen && screen.id !== undefined && screen.id !== null ? String(screen.id) : '';
|
||||||
|
const playerConnectionCount = countsBySlug.has(slug)
|
||||||
|
? countsBySlug.get(slug)
|
||||||
|
: countsById.get(id) || 0;
|
||||||
|
|
||||||
|
return Object.assign({}, screen, {
|
||||||
|
player_connection_count: playerConnectionCount
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await buildDashboardState(pool);
|
|
||||||
if (req.query.edit) {
|
if (req.query.edit) {
|
||||||
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
@@ -23,7 +55,16 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
const editData = await common.fetchScreenEditData(pool);
|
const editData = await common.fetchScreenEditData(pool);
|
||||||
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
}
|
}
|
||||||
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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 dashboardState = await buildDashboardState(pool);
|
||||||
|
const data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE, search, sort, direction);
|
||||||
|
res.send(pages.renderScreensPage({
|
||||||
|
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) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,8 @@ module.exports = function renderSlidesPage(data, message, currentUser) {
|
|||||||
active: 'slides',
|
active: 'slides',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
slides: data.slides || []
|
slides: data.slides || [],
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ module.exports = function registerSlidesRoutes(app, deps) {
|
|||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchAdminData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function buildDefaultTemplate() {
|
|||||||
canvas_size_height: 1080,
|
canvas_size_height: 1080,
|
||||||
background_color: '#111111',
|
background_color: '#111111',
|
||||||
background_image_path: '',
|
background_image_path: '',
|
||||||
|
region_usage: [],
|
||||||
regions: []
|
regions: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -50,6 +51,6 @@ module.exports = function renderTemplateFormPage(template, mode, message, canvas
|
|||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
template: current,
|
template: current,
|
||||||
canvasSizes: canvasSizes || [],
|
canvasSizes: canvasSizes || [],
|
||||||
scripts: ['js/templates/template-designer-utils.js', 'js/templates/template-designer.js']
|
scripts: ['js/lib/modal.js', 'js/templates/template-designer-utils.js', 'js/templates/template-designer.js']
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -6,7 +6,12 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
|||||||
name: template.name,
|
name: template.name,
|
||||||
canvas_size_width: template.canvas_size_width,
|
canvas_size_width: template.canvas_size_width,
|
||||||
canvas_size_height: template.canvas_size_height,
|
canvas_size_height: template.canvas_size_height,
|
||||||
regionCount: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
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
|
||||||
}));
|
}));
|
||||||
|
|
||||||
return renderView('templates/list', {
|
return renderView('templates/list', {
|
||||||
@@ -14,6 +19,7 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
|||||||
active: 'templates',
|
active: 'templates',
|
||||||
message: message,
|
message: message,
|
||||||
currentUser: currentUser || null,
|
currentUser: currentUser || null,
|
||||||
templates: templates
|
templates: templates,
|
||||||
|
pagination: data.pagination || null
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -2,12 +2,22 @@ module.exports = function registerTemplatesRoutes(app, deps) {
|
|||||||
const pages = deps.pages;
|
const pages = deps.pages;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
|
const { buildPagination } = require('../../../lib/pagination');
|
||||||
const requirePermission = deps.requirePermission;
|
const requirePermission = deps.requirePermission;
|
||||||
|
|
||||||
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
|
||||||
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchTemplatesData(pool);
|
const page = Math.max(1, Math.floor(Number(req.query.page) || 1));
|
||||||
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
|
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', { search: search, sort: sort, direction: direction }, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user