Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
07ec17ad91 | ||
|
|
b5fbf79af7 | ||
|
|
4ca4da473f | ||
|
|
f4de31269d | ||
|
|
235d2da6aa | ||
|
|
d24c9c4035 | ||
|
|
04ddec50ad | ||
|
|
47613e61a2 | ||
|
|
bd130a1070 | ||
|
|
d48f0e779f | ||
|
|
90ec3bb2df | ||
|
|
0ca20035cd | ||
|
|
c18f597068 | ||
|
|
0e89892c94 | ||
|
|
9ee938fd2f | ||
|
|
ad514102e7 | ||
|
|
cceb5d8fde | ||
|
|
b0e72e40d4 |
@@ -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.
|
||||||
@@ -6,6 +6,102 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- No unreleased changes recorded yet.
|
- No unreleased changes recorded yet.
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Slide thumbnail refresh now uses the player-rendered preview again and signs the preview GET request in the same way the player verifies it.
|
||||||
|
- Background tasks settings page and client script were repaired after a syntax break so the web app can boot cleanly.
|
||||||
|
|
||||||
|
## 1.5.7 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Upload removal requests now authenticate correctly when a deployed player parses a bodyless `DELETE` request as an empty object, which could cause 401s for newly uploaded images.
|
||||||
|
|
||||||
|
## 1.5.6 - 2026-07-25
|
||||||
|
|
||||||
|
### 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
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed player media upload sync so signed upload requests are verified after the request body has been parsed.
|
||||||
|
|
||||||
|
## 1.5.1 - 2026-07-25
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fixed the account page so it can save changes again.
|
||||||
|
|
||||||
## 1.5.0 - 2026-07-25
|
## 1.5.0 - 2026-07-25
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
+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
|
||||||
|
|||||||
@@ -87,5 +87,6 @@ Outside Docker, the web app and player do not have to use the same upload locati
|
|||||||
|
|
||||||
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
- The web app must be able to reach the player through `PLAYER_INTERNAL_BASE_URL`.
|
||||||
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
- When running in Docker, that value should point to the Docker service name, not `localhost`.
|
||||||
|
- If `PULSE_SIGNAGE_SHARED_SECRET` is set, the web and player containers must use the same value, and any reverse proxy in front of the player must forward `/api/media/...` without rewriting the path.
|
||||||
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
- Uploaded media is stored separately from the application source, so make sure it is backed up if you are not using Docker volumes.
|
||||||
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
|
- The player page uses the browser Screen Wake Lock API when available, but kiosk mode and OS sleep settings still matter because the browser can deny or release the wake lock.
|
||||||
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.0",
|
"version": "1.5.10",
|
||||||
"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": {
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ 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,
|
||||||
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,
|
||||||
|
|||||||
+84
-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.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
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,84 @@ 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) {
|
||||||
|
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',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ playlists: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchSlidesPage(pool, page, pageSize) {
|
||||||
|
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',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ slides: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplatesPage(pool, page, pageSize) {
|
||||||
|
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
|
||||||
|
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',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ templates: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchCanvasSizesPage(pool, page, pageSize) {
|
||||||
|
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',
|
||||||
|
page: page,
|
||||||
|
pageSize: pageSize
|
||||||
|
});
|
||||||
|
|
||||||
|
return Object.assign({ canvasSizes: paged.rows }, paged);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchScreensPage(pool, page, pageSize) {
|
||||||
|
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',
|
||||||
|
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,17 @@ async function fetchApiSourcesData(pool) {
|
|||||||
return { apiSources: apiSources };
|
return { apiSources: apiSources };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchApiSourcesPage(pool, page, pageSize) {
|
||||||
|
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',
|
||||||
|
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 +156,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,21 @@
|
|||||||
|
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) {
|
||||||
|
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',
|
||||||
|
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 +41,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,17 @@ async function fetchRssFeedsData(pool) {
|
|||||||
return { rssFeeds: rssFeeds };
|
return { rssFeeds: rssFeeds };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchRssFeedsPage(pool, page, pageSize) {
|
||||||
|
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',
|
||||||
|
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 +297,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 };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-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,7 @@ 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 === 'webpage') {
|
} else if (region.region_type === 'webpage') {
|
||||||
const submitted = body[`region_webpage_${region.id}`];
|
const submitted = body[`region_webpage_${region.id}`];
|
||||||
|
|||||||
@@ -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,38 @@ 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 [countRows] = await pool.query(countSql, params);
|
||||||
|
const totalItems = Number(countRows && countRows[0] && countRows[0].count) || 0;
|
||||||
|
const totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
const safeCurrentPage = Math.min(currentPage, totalPages);
|
||||||
|
const offset = (safeCurrentPage - 1) * pageSize;
|
||||||
|
const [rows] = await pool.query(`${selectSql} LIMIT ? OFFSET ?`, params.concat([pageSize, offset]));
|
||||||
|
|
||||||
|
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 +76,7 @@ async function fetchDuplicateName(pool, tableName, name, excludeId, columnName)
|
|||||||
module.exports = {
|
module.exports = {
|
||||||
parseJsonSafe,
|
parseJsonSafe,
|
||||||
readFormArray,
|
readFormArray,
|
||||||
|
normalizePageNumber,
|
||||||
|
fetchPagedRows,
|
||||||
fetchDuplicateName
|
fetchDuplicateName
|
||||||
};
|
};
|
||||||
|
|||||||
+4
-2
@@ -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,
|
||||||
@@ -341,7 +343,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]);
|
||||||
|
|||||||
+244
-2
@@ -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 (
|
||||||
@@ -410,6 +614,7 @@ const migrations = [
|
|||||||
|
|
||||||
// v1.4.6: playlists gained fade_between_slides plus audit fields.
|
// v1.4.6: playlists gained fade_between_slides plus 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');
|
||||||
|
|
||||||
@@ -433,6 +638,7 @@ const migrations = [
|
|||||||
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');
|
||||||
|
|
||||||
@@ -517,6 +723,24 @@ 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: 'media-path-prefix-rename',
|
key: 'media-path-prefix-rename',
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
@@ -526,6 +750,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 +798,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 +818,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,6 +6,7 @@
|
|||||||
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 = [];
|
||||||
@@ -16,6 +17,7 @@
|
|||||||
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 +156,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();
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ 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,
|
||||||
@@ -180,6 +180,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);
|
||||||
|
|||||||
@@ -397,6 +397,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 {
|
||||||
|
|||||||
@@ -89,6 +89,12 @@ function scheduleSlideAdvance(delayMs) {
|
|||||||
}, holdDelayMs);
|
}, holdDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add the fade time to a slide's hold duration so the configured duration remains visible.
|
||||||
|
function getSlideHoldDelay(delayMs) {
|
||||||
|
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
||||||
|
return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0);
|
||||||
|
}
|
||||||
|
|
||||||
// Cancel any pending fade-transition cleanup.
|
// Cancel any pending fade-transition cleanup.
|
||||||
function clearSlideTransitionTimer() {
|
function clearSlideTransitionTimer() {
|
||||||
if (slideTransitionTimer) {
|
if (slideTransitionTimer) {
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -469,7 +485,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,14 +502,16 @@ 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.
|
||||||
|
|||||||
+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();
|
||||||
|
|||||||
@@ -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', requireRequestAuth, express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
|
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+119
-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();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,6 +211,7 @@ async function start() {
|
|||||||
|
|
||||||
registerAdminAccountRoutes(app, {
|
registerAdminAccountRoutes(app, {
|
||||||
pool: pool,
|
pool: pool,
|
||||||
|
common: common,
|
||||||
pages: pages,
|
pages: pages,
|
||||||
formatDashboardDate: formatDashboardDate,
|
formatDashboardDate: formatDashboardDate,
|
||||||
getAuditUserId: getAuditUserId,
|
getAuditUserId: getAuditUserId,
|
||||||
@@ -254,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,
|
||||||
@@ -339,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() {
|
||||||
@@ -380,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,
|
||||||
|
|||||||
@@ -0,0 +1,79 @@
|
|||||||
|
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 buildPaginationPages(totalPages, currentPage, pageParam, queryState, maxVisiblePages) {
|
||||||
|
const pages = [];
|
||||||
|
const visiblePageCount = Math.max(1, Math.min(Number(maxVisiblePages) || 7, totalPages));
|
||||||
|
let startPage = Math.max(1, currentPage - Math.floor(visiblePageCount / 2));
|
||||||
|
let endPage = startPage + visiblePageCount - 1;
|
||||||
|
|
||||||
|
if (endPage > totalPages) {
|
||||||
|
endPage = totalPages;
|
||||||
|
startPage = Math.max(1, endPage - visiblePageCount + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (let pageNumber = startPage; pageNumber <= endPage; pageNumber += 1) {
|
||||||
|
const nextQuery = Object.assign({}, queryState, { [pageParam]: pageNumber });
|
||||||
|
pages.push({
|
||||||
|
number: pageNumber,
|
||||||
|
active: pageNumber === currentPage,
|
||||||
|
url: buildQueryString(nextQuery)
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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 });
|
||||||
|
|
||||||
|
return {
|
||||||
|
currentPage: safeCurrentPage,
|
||||||
|
totalPages: totalPages,
|
||||||
|
totalItems: Number(totalItems) || 0,
|
||||||
|
hasMultiplePages: totalPages > 1,
|
||||||
|
startItem: startIndex + 1,
|
||||||
|
endItem: endIndex,
|
||||||
|
hasPrevious: safeCurrentPage > 1,
|
||||||
|
hasNext: safeCurrentPage < totalPages,
|
||||||
|
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,21 @@ async function fetchRoles(pool) {
|
|||||||
return rows || [];
|
return rows || [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchRolesPage(pool, page, pageSize) {
|
||||||
|
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',
|
||||||
|
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 +122,40 @@ async function fetchUsersWithRoles(pool) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function fetchUsersWithRolesPage(pool, page, pageSize) {
|
||||||
|
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',
|
||||||
|
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 +234,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,284 @@
|
|||||||
|
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 === '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
|
||||||
|
};
|
||||||
+98
-14
@@ -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 = 100 * 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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
@@ -761,6 +787,242 @@ 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-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;
|
||||||
@@ -1028,7 +1290,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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1182,6 +1444,177 @@ 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-meta {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.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 +1675,67 @@ td[data-label="Actions"] > div {
|
|||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-duration-input {
|
||||||
|
width: 6rem;
|
||||||
|
min-width: 6rem;
|
||||||
|
}
|
||||||
|
|
||||||
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;
|
||||||
|
|||||||
@@ -434,6 +434,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 = '';
|
||||||
|
|||||||
@@ -234,6 +234,12 @@
|
|||||||
if (typeof window.initLocalDateTimes === 'function') {
|
if (typeof window.initLocalDateTimes === 'function') {
|
||||||
window.initLocalDateTimes(targetElement);
|
window.initLocalDateTimes(targetElement);
|
||||||
}
|
}
|
||||||
|
if (typeof window.initTableSearches === 'function') {
|
||||||
|
window.initTableSearches(targetElement);
|
||||||
|
}
|
||||||
|
if (typeof window.initTablePaginations === 'function') {
|
||||||
|
window.initTablePaginations(targetElement);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
||||||
@@ -434,6 +440,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,6 +588,332 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initTablePaginations(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-pagination="auto"]'), function (container) {
|
||||||
|
if (container.getAttribute('data-table-pagination-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var table = container.querySelector('[data-table-searchable]');
|
||||||
|
var cardHeader = container.querySelector('.card-header');
|
||||||
|
var cardBody = container.querySelector('.card-body.table-responsive') || container.querySelector('.card-body');
|
||||||
|
var footer = container.querySelector('[data-table-pagination-controls]');
|
||||||
|
var footerSummary = null;
|
||||||
|
var footerList = null;
|
||||||
|
var syncFrame = 0;
|
||||||
|
var state = {
|
||||||
|
currentPage: 1,
|
||||||
|
pageSize: 1,
|
||||||
|
totalPages: 1
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!table || !cardBody || !table.tBodies.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.setAttribute('data-table-pagination-bound', 'true');
|
||||||
|
|
||||||
|
function getRows() {
|
||||||
|
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function isSearchMatched(row) {
|
||||||
|
return String(row.getAttribute('data-table-search-match') || 'true') !== 'false';
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureFooter() {
|
||||||
|
if (footer) {
|
||||||
|
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||||
|
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||||
|
return footer;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer = document.createElement('div');
|
||||||
|
footer.className = 'card-footer d-none';
|
||||||
|
footer.setAttribute('data-table-pagination-controls', 'true');
|
||||||
|
footer.innerHTML = '' +
|
||||||
|
'<div class="d-flex flex-wrap align-items-center justify-content-between gap-3">' +
|
||||||
|
'<div class="text-muted small" data-table-pagination-summary></div>' +
|
||||||
|
'<nav aria-label="Table pages">' +
|
||||||
|
'<ul class="pagination pagination-sm mb-0" data-table-pagination-list></ul>' +
|
||||||
|
'</nav>' +
|
||||||
|
'</div>';
|
||||||
|
cardBody.parentNode.insertBefore(footer, cardBody.nextSibling);
|
||||||
|
footerSummary = footer.querySelector('[data-table-pagination-summary]');
|
||||||
|
footerList = footer.querySelector('[data-table-pagination-list]');
|
||||||
|
return footer;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRowVisible(row, visible) {
|
||||||
|
row.hidden = !visible;
|
||||||
|
row.classList.toggle('d-none', !visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAvailableHeight() {
|
||||||
|
var containerRect = container.getBoundingClientRect();
|
||||||
|
var headerHeight = cardHeader ? cardHeader.getBoundingClientRect().height : 0;
|
||||||
|
var footerEstimate = footer && !footer.classList.contains('d-none') ? footer.getBoundingClientRect().height : 56;
|
||||||
|
return Math.max(0, window.innerHeight - Math.max(0, containerRect.top) - headerHeight - footerEstimate - 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderFooter(totalItems) {
|
||||||
|
var pageButtons = [];
|
||||||
|
|
||||||
|
ensureFooter();
|
||||||
|
|
||||||
|
if (state.totalPages <= 1 || totalItems <= 0) {
|
||||||
|
footer.classList.add('d-none');
|
||||||
|
footerSummary.textContent = '';
|
||||||
|
footerList.innerHTML = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer.classList.remove('d-none');
|
||||||
|
footerSummary.textContent = 'Showing ' + (((state.currentPage - 1) * state.pageSize) + 1) + '-' + Math.min(totalItems, state.currentPage * state.pageSize) + ' of ' + totalItems;
|
||||||
|
|
||||||
|
pageButtons.push('<li class="page-item' + (state.currentPage <= 1 ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage - 1) + '" aria-label="Previous page">Previous</a></li>');
|
||||||
|
for (var pageNumber = 1; pageNumber <= state.totalPages; pageNumber += 1) {
|
||||||
|
pageButtons.push('<li class="page-item' + (pageNumber === state.currentPage ? ' active' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + pageNumber + '">' + pageNumber + '</a></li>');
|
||||||
|
}
|
||||||
|
pageButtons.push('<li class="page-item' + (state.currentPage >= state.totalPages ? ' disabled' : '') + '"><a class="page-link" href="#" data-table-pagination-page="' + (state.currentPage + 1) + '" aria-label="Next page">Next</a></li>');
|
||||||
|
footerList.innerHTML = pageButtons.join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPagination(resetPage) {
|
||||||
|
var rows = getRows();
|
||||||
|
var matchedRows = rows.filter(isSearchMatched);
|
||||||
|
var totalItems = matchedRows.length;
|
||||||
|
var rowHeight = 48;
|
||||||
|
var pageSize;
|
||||||
|
var startIndex;
|
||||||
|
var endIndex;
|
||||||
|
|
||||||
|
if (resetPage) {
|
||||||
|
state.currentPage = 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
if (!isSearchMatched(row)) {
|
||||||
|
setRowVisible(row, false);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!totalItems) {
|
||||||
|
state.pageSize = 1;
|
||||||
|
state.totalPages = 1;
|
||||||
|
state.currentPage = 1;
|
||||||
|
renderFooter(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
matchedRows.forEach(function (row) {
|
||||||
|
setRowVisible(row, true);
|
||||||
|
});
|
||||||
|
|
||||||
|
if (matchedRows[0]) {
|
||||||
|
rowHeight = Math.max(24, matchedRows[0].getBoundingClientRect().height || matchedRows[0].offsetHeight || 48);
|
||||||
|
}
|
||||||
|
|
||||||
|
pageSize = Math.max(1, Math.floor(getAvailableHeight() / rowHeight));
|
||||||
|
|
||||||
|
if (pageSize >= totalItems) {
|
||||||
|
state.pageSize = totalItems;
|
||||||
|
state.totalPages = 1;
|
||||||
|
state.currentPage = 1;
|
||||||
|
matchedRows.forEach(function (row) {
|
||||||
|
setRowVisible(row, true);
|
||||||
|
});
|
||||||
|
renderFooter(totalItems);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.pageSize = pageSize;
|
||||||
|
state.totalPages = Math.max(1, Math.ceil(totalItems / pageSize));
|
||||||
|
state.currentPage = Math.min(Math.max(1, state.currentPage), state.totalPages);
|
||||||
|
startIndex = (state.currentPage - 1) * pageSize;
|
||||||
|
endIndex = startIndex + pageSize;
|
||||||
|
|
||||||
|
matchedRows.forEach(function (row, index) {
|
||||||
|
setRowVisible(row, index >= startIndex && index < endIndex);
|
||||||
|
});
|
||||||
|
|
||||||
|
renderFooter(totalItems);
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleSync(options) {
|
||||||
|
if (syncFrame) {
|
||||||
|
window.cancelAnimationFrame(syncFrame);
|
||||||
|
}
|
||||||
|
|
||||||
|
syncFrame = window.requestAnimationFrame(function () {
|
||||||
|
syncFrame = 0;
|
||||||
|
applyPagination(Boolean(options && options.resetPage));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
ensureFooter();
|
||||||
|
|
||||||
|
if (footer) {
|
||||||
|
footer.addEventListener('click', function (event) {
|
||||||
|
var target = event.target && event.target.closest ? event.target.closest('[data-table-pagination-page]') : null;
|
||||||
|
var pageValue;
|
||||||
|
|
||||||
|
if (!target || target.closest('.disabled')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
pageValue = Math.max(1, Math.floor(Number(target.getAttribute('data-table-pagination-page')) || 1));
|
||||||
|
state.currentPage = pageValue;
|
||||||
|
scheduleSync();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.addEventListener('resize', function () {
|
||||||
|
scheduleSync();
|
||||||
|
});
|
||||||
|
|
||||||
|
if (typeof ResizeObserver === 'function') {
|
||||||
|
try {
|
||||||
|
new ResizeObserver(function () {
|
||||||
|
scheduleSync();
|
||||||
|
}).observe(container);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore observer setup failures and rely on window resize.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
container.syncTablePagination = scheduleSync;
|
||||||
|
scheduleSync({ resetPage: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function initTableSearches(root) {
|
||||||
|
var scope = root && root.querySelectorAll ? root : document;
|
||||||
|
|
||||||
|
Array.prototype.forEach.call(scope.querySelectorAll('[data-table-search]'), function (input) {
|
||||||
|
if (input.getAttribute('data-table-search-bound') === 'true') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var container = input.closest('[data-table-search-container]') || input.closest('.card') || document;
|
||||||
|
var table = container.querySelector('[data-table-searchable]');
|
||||||
|
var emptyRow = null;
|
||||||
|
|
||||||
|
if (!table) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
input.setAttribute('data-table-search-bound', 'true');
|
||||||
|
|
||||||
|
function getSearchableRows() {
|
||||||
|
return Array.prototype.slice.call(table.querySelectorAll('tbody tr[data-table-search-row]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDefaultEmptyRow() {
|
||||||
|
return table.querySelector('tbody tr[data-table-search-empty-default]');
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeGeneratedEmptyRow() {
|
||||||
|
if (emptyRow && emptyRow.parentNode) {
|
||||||
|
emptyRow.parentNode.removeChild(emptyRow);
|
||||||
|
}
|
||||||
|
emptyRow = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureGeneratedEmptyRow(message) {
|
||||||
|
var tbody = table.tBodies[0] || table.querySelector('tbody');
|
||||||
|
var columnCount = 1;
|
||||||
|
|
||||||
|
if (!tbody) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!emptyRow) {
|
||||||
|
emptyRow = document.createElement('tr');
|
||||||
|
emptyRow.setAttribute('data-table-search-empty-row', 'true');
|
||||||
|
var cell = document.createElement('td');
|
||||||
|
cell.className = 'empty';
|
||||||
|
cell.setAttribute('data-table-search-empty-cell', 'true');
|
||||||
|
emptyRow.appendChild(cell);
|
||||||
|
}
|
||||||
|
|
||||||
|
columnCount = table.tHead && table.tHead.rows && table.tHead.rows[0] ? table.tHead.rows[0].cells.length : (getSearchableRows()[0] ? getSearchableRows()[0].cells.length : 1);
|
||||||
|
emptyRow.firstElementChild.colSpan = Math.max(1, columnCount);
|
||||||
|
emptyRow.firstElementChild.textContent = message;
|
||||||
|
|
||||||
|
if (!emptyRow.parentNode) {
|
||||||
|
tbody.appendChild(emptyRow);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSearch() {
|
||||||
|
var query = String(input.value || '').trim().toLowerCase();
|
||||||
|
var rows = getSearchableRows();
|
||||||
|
var visibleCount = 0;
|
||||||
|
var defaultEmptyRow = getDefaultEmptyRow();
|
||||||
|
|
||||||
|
removeGeneratedEmptyRow();
|
||||||
|
|
||||||
|
rows.forEach(function (row) {
|
||||||
|
var haystack = String(row.getAttribute('data-search-text') || row.textContent || '').toLowerCase();
|
||||||
|
var visible = !query || haystack.indexOf(query) !== -1;
|
||||||
|
row.setAttribute('data-table-search-match', visible ? 'true' : 'false');
|
||||||
|
row.classList.toggle('d-none', !visible);
|
||||||
|
row.hidden = !visible;
|
||||||
|
if (visible) {
|
||||||
|
visibleCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (defaultEmptyRow) {
|
||||||
|
defaultEmptyRow.classList.toggle('d-none', Boolean(query));
|
||||||
|
defaultEmptyRow.hidden = Boolean(query);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (query && visibleCount === 0) {
|
||||||
|
ensureGeneratedEmptyRow('No results match your search.');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof window.syncTablePagination === 'function') {
|
||||||
|
window.syncTablePagination(container, { resetPage: true });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
input.addEventListener('input', syncSearch);
|
||||||
|
syncSearch();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (window.initSortableTables) {
|
if (window.initSortableTables) {
|
||||||
window.initSortableTables();
|
window.initSortableTables();
|
||||||
}
|
}
|
||||||
@@ -589,6 +926,20 @@
|
|||||||
initSubmitOnChange();
|
initSubmitOnChange();
|
||||||
initJsonTogglePanels();
|
initJsonTogglePanels();
|
||||||
initLocalDateTimes();
|
initLocalDateTimes();
|
||||||
|
initTableSearches();
|
||||||
|
initTablePaginations();
|
||||||
|
attachSlideThumbFallbacks(document);
|
||||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
window.initJsonTogglePanels = initJsonTogglePanels;
|
||||||
window.initLocalDateTimes = initLocalDateTimes;
|
window.initLocalDateTimes = initLocalDateTimes;
|
||||||
|
window.initTableSearches = initTableSearches;
|
||||||
|
window.initTablePaginations = initTablePaginations;
|
||||||
|
window.syncTablePagination = function (container, options) {
|
||||||
|
if (!container || !container.syncTablePagination) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.syncTablePagination(options);
|
||||||
|
};
|
||||||
|
window.createSlideThumbPlaceholder = createSlideThumbPlaceholder;
|
||||||
|
window.attachSlideThumbFallbacks = attachSlideThumbFallbacks;
|
||||||
}());
|
}());
|
||||||
|
|||||||
@@ -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
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -305,16 +305,273 @@
|
|||||||
function initPlaylistEditStaging() {
|
function initPlaylistEditStaging() {
|
||||||
var tbody = document.getElementById('playlist-items-body');
|
var tbody = document.getElementById('playlist-items-body');
|
||||||
var form = document.getElementById('playlist-edit-form');
|
var form = document.getElementById('playlist-edit-form');
|
||||||
var addSection = document.getElementById('playlist-add-section');
|
var addSlideModal = document.getElementById('playlist-add-slide-modal');
|
||||||
var addSlideForm = document.getElementById('playlist-add-slide-form');
|
var addSlideGrid = document.getElementById('playlist-slide-picker-grid');
|
||||||
var addSlideButton = document.getElementById('playlist-add-slide-button');
|
var addSlideSearch = document.getElementById('playlist-slide-picker-search');
|
||||||
var addSlideSelect = document.getElementById('playlist-add-slide-select');
|
var addSlideShowAssigned = document.getElementById('playlist-slide-picker-show-assigned');
|
||||||
var addSlideEmpty = document.getElementById('playlist-add-slide-empty');
|
var addSlideEmpty = document.getElementById('playlist-slide-picker-empty');
|
||||||
|
var addSlideConfirm = document.getElementById('playlist-confirm-add-slides');
|
||||||
|
var addSlideCount = document.getElementById('playlist-slide-picker-selected-count');
|
||||||
|
var addSlideData = document.getElementById('playlist-add-slide-data');
|
||||||
|
var addSlideOpenButton = document.getElementById('playlist-open-slide-modal');
|
||||||
|
var slidePickerCards = [];
|
||||||
|
var slidePickerSelection = new Set();
|
||||||
|
var slidePickerSlides = [];
|
||||||
|
|
||||||
if (!tbody || !form) {
|
if (!tbody || !form) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (addSlideData) {
|
||||||
|
try {
|
||||||
|
var parsedSlides = JSON.parse(addSlideData.textContent || '[]');
|
||||||
|
slidePickerSlides = Array.isArray(parsedSlides) ? parsedSlides : [];
|
||||||
|
} catch (_error) {
|
||||||
|
slidePickerSlides = [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getModalInstance() {
|
||||||
|
if (!window.pulseModal) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return window.pulseModal.getOrCreate(addSlideModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCardSelected(card, selected) {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.classList.toggle('is-selected', Boolean(selected));
|
||||||
|
card.setAttribute('aria-pressed', Boolean(selected) ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setCardVisible(card, visible) {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
card.classList.toggle('is-hidden', !visible);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearModalSelection() {
|
||||||
|
slidePickerSelection.clear();
|
||||||
|
slidePickerCards.forEach(function (card) {
|
||||||
|
setCardSelected(card, false);
|
||||||
|
});
|
||||||
|
if (addSlideSearch) {
|
||||||
|
addSlideSearch.value = '';
|
||||||
|
}
|
||||||
|
syncSlidePickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachSlideThumbFallbacks(root) {
|
||||||
|
if (typeof window.attachSlideThumbFallbacks === 'function') {
|
||||||
|
window.attachSlideThumbFallbacks(root || tbody);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPickerThumb(slide) {
|
||||||
|
function createPlaceholder() {
|
||||||
|
var placeholder = document.createElement('div');
|
||||||
|
var icon = document.createElement('i');
|
||||||
|
var label = document.createElement('span');
|
||||||
|
|
||||||
|
placeholder.className = 'playlist-slide-picker-thumb-placeholder';
|
||||||
|
icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon';
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
label.className = 'playlist-slide-picker-thumb-placeholder-label';
|
||||||
|
label.textContent = 'No thumbnail';
|
||||||
|
|
||||||
|
placeholder.appendChild(icon);
|
||||||
|
placeholder.appendChild(label);
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (slide && slide.thumbnail_path) {
|
||||||
|
var image = document.createElement('img');
|
||||||
|
image.className = 'playlist-slide-picker-thumb-image';
|
||||||
|
image.loading = 'lazy';
|
||||||
|
image.alt = String(slide.title || 'Slide');
|
||||||
|
image.src = String(slide.thumbnail_path || '');
|
||||||
|
image.addEventListener('error', function () {
|
||||||
|
if (image.parentNode) {
|
||||||
|
image.parentNode.replaceChild(createPlaceholder(), image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
return createPlaceholder();
|
||||||
|
}
|
||||||
|
|
||||||
|
function createPickerCard(slide) {
|
||||||
|
var button = document.createElement('button');
|
||||||
|
var media = document.createElement('div');
|
||||||
|
var title = document.createElement('div');
|
||||||
|
var check = document.createElement('span');
|
||||||
|
var badge = document.createElement('span');
|
||||||
|
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
||||||
|
|
||||||
|
button.type = 'button';
|
||||||
|
button.className = 'playlist-slide-picker-card';
|
||||||
|
button.setAttribute('aria-pressed', 'false');
|
||||||
|
button.setAttribute('data-slide-id', String(slide.id || ''));
|
||||||
|
button.setAttribute('data-search-text', searchText);
|
||||||
|
button.setAttribute('data-is-assigned', slide && slide.isAssigned ? 'true' : 'false');
|
||||||
|
|
||||||
|
if (slide && slide.isAssigned) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.classList.add('is-assigned');
|
||||||
|
}
|
||||||
|
|
||||||
|
media.className = 'playlist-slide-picker-media';
|
||||||
|
media.appendChild(createPickerThumb(slide));
|
||||||
|
check.className = 'playlist-slide-picker-check bi bi-check2-circle';
|
||||||
|
check.setAttribute('aria-hidden', 'true');
|
||||||
|
media.appendChild(check);
|
||||||
|
|
||||||
|
badge.className = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||||
|
badge.textContent = 'Already in playlist';
|
||||||
|
badge.setAttribute('aria-hidden', 'true');
|
||||||
|
media.appendChild(badge);
|
||||||
|
|
||||||
|
title.className = 'playlist-slide-picker-title';
|
||||||
|
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
||||||
|
|
||||||
|
button.appendChild(media);
|
||||||
|
button.appendChild(title);
|
||||||
|
|
||||||
|
return button;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setShowAssignedState(showAssignedSlides) {
|
||||||
|
if (!addSlideShowAssigned) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSlideShowAssigned.setAttribute('aria-pressed', showAssignedSlides ? 'true' : 'false');
|
||||||
|
addSlideShowAssigned.classList.toggle('btn-secondary', showAssignedSlides);
|
||||||
|
addSlideShowAssigned.classList.toggle('btn-outline-secondary', !showAssignedSlides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSlidePicker() {
|
||||||
|
if (!addSlideGrid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
addSlideGrid.innerHTML = '';
|
||||||
|
slidePickerCards = slidePickerSlides.map(function (slide) {
|
||||||
|
var card = createPickerCard(slide);
|
||||||
|
addSlideGrid.appendChild(card);
|
||||||
|
return card;
|
||||||
|
});
|
||||||
|
syncSlidePickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncSlidePickerState() {
|
||||||
|
var query = String(addSlideSearch && addSlideSearch.value ? addSlideSearch.value : '').trim().toLowerCase();
|
||||||
|
var showAssignedSlides = Boolean(addSlideShowAssigned && addSlideShowAssigned.getAttribute('aria-pressed') === 'true');
|
||||||
|
var visibleCount = 0;
|
||||||
|
var assignedCount = 0;
|
||||||
|
|
||||||
|
slidePickerCards.forEach(function (card) {
|
||||||
|
var searchText = String(card.getAttribute('data-search-text') || '');
|
||||||
|
var isAssigned = card.getAttribute('data-is-assigned') === 'true';
|
||||||
|
var matchesSearch = !query || searchText.indexOf(query) !== -1;
|
||||||
|
var matchesAssignment = showAssignedSlides || !isAssigned;
|
||||||
|
var matches = matchesSearch && matchesAssignment;
|
||||||
|
setCardVisible(card, matches);
|
||||||
|
if (isAssigned) {
|
||||||
|
assignedCount += 1;
|
||||||
|
}
|
||||||
|
if (matches) {
|
||||||
|
visibleCount += 1;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (addSlideEmpty) {
|
||||||
|
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||||
|
addSlideEmpty.textContent = query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||||
|
} else {
|
||||||
|
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||||
|
}
|
||||||
|
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addSlideCount) {
|
||||||
|
addSlideCount.textContent = String(slidePickerSelection.size);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (addSlideConfirm) {
|
||||||
|
addSlideConfirm.disabled = slidePickerSelection.size === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowAssignedState(showAssignedSlides);
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleCardSelection(card) {
|
||||||
|
var slideId = String(card && card.getAttribute('data-slide-id') ? card.getAttribute('data-slide-id') : '');
|
||||||
|
var selected;
|
||||||
|
|
||||||
|
if (!slideId || card.disabled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
selected = !slidePickerSelection.has(slideId);
|
||||||
|
if (selected) {
|
||||||
|
slidePickerSelection.add(slideId);
|
||||||
|
} else {
|
||||||
|
slidePickerSelection.delete(slideId);
|
||||||
|
}
|
||||||
|
setCardSelected(card, selected);
|
||||||
|
syncSlidePickerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSelectedSlides() {
|
||||||
|
var selectedSlides = slidePickerSlides.filter(function (slide) {
|
||||||
|
return slidePickerSelection.has(String(slide.id || '')) && !slide.isAssigned;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!selectedSlides.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tbody.querySelector('.playlist-empty-row')) {
|
||||||
|
tbody.querySelector('.playlist-empty-row').remove();
|
||||||
|
}
|
||||||
|
|
||||||
|
selectedSlides.forEach(function (slide) {
|
||||||
|
var row = createRow({
|
||||||
|
row_key: 'new-' + Date.now() + '-' + slide.id,
|
||||||
|
slide_id: String(slide.id),
|
||||||
|
thumbnail_path: String(slide.thumbnail_path || ''),
|
||||||
|
canvas_width: slide.canvas_width,
|
||||||
|
canvas_height: slide.canvas_height,
|
||||||
|
canvas_signature: String(slide.canvasSignature || ''),
|
||||||
|
title: slide.title || 'Slide',
|
||||||
|
duration_seconds: 10,
|
||||||
|
schedule_mode: 'always',
|
||||||
|
schedule_start_datetime: '',
|
||||||
|
schedule_end_datetime: '',
|
||||||
|
schedule_start_time: '',
|
||||||
|
schedule_end_time: '',
|
||||||
|
schedule_days_json: '[]',
|
||||||
|
summary: 'Always visible'
|
||||||
|
});
|
||||||
|
tbody.appendChild(row);
|
||||||
|
});
|
||||||
|
|
||||||
|
attachSlideThumbFallbacks(tbody);
|
||||||
|
|
||||||
|
updateRowOrder(true);
|
||||||
|
|
||||||
|
if (!window.pulseModal || !window.pulseModal.hide(addSlideModal)) {
|
||||||
|
addSlideModal.classList.remove('show');
|
||||||
|
addSlideModal.setAttribute('aria-hidden', 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
attachSlideThumbFallbacks(tbody);
|
||||||
|
|
||||||
function getRows() {
|
function getRows() {
|
||||||
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]'));
|
return Array.prototype.slice.call(tbody.querySelectorAll('tr[data-playlist-slide-row]'));
|
||||||
}
|
}
|
||||||
@@ -375,9 +632,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function syncAddSlideOptions() {
|
function syncAddSlideOptions() {
|
||||||
if (!addSlideSelect) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var activeSlideIds = {};
|
var activeSlideIds = {};
|
||||||
var activeCanvasSignatures = {};
|
var activeCanvasSignatures = {};
|
||||||
getRows().forEach(function (row) {
|
getRows().forEach(function (row) {
|
||||||
@@ -397,51 +651,40 @@
|
|||||||
allowedCanvasSignature = canvasKeys[0];
|
allowedCanvasSignature = canvasKeys[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
Array.prototype.forEach.call(addSlideSelect.options, function (option) {
|
slidePickerSlides.forEach(function (slide) {
|
||||||
if (!option.value) {
|
var slideId = String(slide.id || '');
|
||||||
return;
|
var card = slidePickerCards.find(function (item) {
|
||||||
|
return String(item.getAttribute('data-slide-id') || '') === slideId;
|
||||||
|
});
|
||||||
|
var canvasSignature = String(slide.canvasSignature || '');
|
||||||
|
var isAssigned = Boolean(activeSlideIds[slideId]);
|
||||||
|
var isCanvasMismatch = Boolean(allowedCanvasSignature && canvasSignature && canvasSignature !== allowedCanvasSignature);
|
||||||
|
|
||||||
|
slide.isAssigned = isAssigned;
|
||||||
|
|
||||||
|
if (card) {
|
||||||
|
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||||
|
card.disabled = isAssigned || isCanvasMismatch;
|
||||||
|
card.classList.toggle('is-assigned', isAssigned);
|
||||||
|
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||||
|
card.classList.toggle('is-hidden', false);
|
||||||
|
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
||||||
}
|
}
|
||||||
var optionCanvasSignature = String(option.getAttribute('data-canvas-signature') || '');
|
if (isAssigned) {
|
||||||
var isActive = Boolean(activeSlideIds[String(option.value)]);
|
slidePickerSelection.delete(slideId);
|
||||||
var isCanvasMismatch = Boolean(allowedCanvasSignature && optionCanvasSignature && optionCanvasSignature !== allowedCanvasSignature);
|
if (card) {
|
||||||
option.disabled = isActive || isCanvasMismatch;
|
setCardSelected(card, false);
|
||||||
option.hidden = isActive || isCanvasMismatch;
|
|
||||||
});
|
|
||||||
if (
|
|
||||||
addSlideSelect.options[addSlideSelect.selectedIndex]
|
|
||||||
&& (addSlideSelect.options[addSlideSelect.selectedIndex].disabled || addSlideSelect.options[addSlideSelect.selectedIndex].hidden)
|
|
||||||
) {
|
|
||||||
for (var i = 0; i < addSlideSelect.options.length; i += 1) {
|
|
||||||
if (!addSlideSelect.options[i].disabled && !addSlideSelect.options[i].hidden) {
|
|
||||||
addSlideSelect.selectedIndex = i;
|
|
||||||
break;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
var availableCount = 0;
|
|
||||||
Array.prototype.forEach.call(addSlideSelect.options, function (option) {
|
|
||||||
if (!option.value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!option.disabled && !option.hidden) {
|
|
||||||
availableCount += 1;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (addSlideButton) {
|
if (addSlideOpenButton) {
|
||||||
addSlideButton.disabled = availableCount === 0;
|
addSlideOpenButton.disabled = slidePickerCards.filter(function (card) {
|
||||||
}
|
return !card.disabled;
|
||||||
if (addSlideSelect) {
|
}).length === 0;
|
||||||
addSlideSelect.disabled = availableCount === 0;
|
|
||||||
}
|
|
||||||
if (addSlideEmpty) {
|
|
||||||
addSlideEmpty.classList.toggle('is-hidden', availableCount !== 0);
|
|
||||||
}
|
|
||||||
if (addSection && addSlideForm && addSlideSelect && addSlideButton) {
|
|
||||||
var controlsVisible = availableCount > 0;
|
|
||||||
addSlideForm.classList.toggle('is-hidden', !controlsVisible);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
syncSlidePickerState();
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateRowOrder(markDirty) {
|
function updateRowOrder(markDirty) {
|
||||||
@@ -530,6 +773,15 @@
|
|||||||
var row = document.createElement('tr');
|
var row = document.createElement('tr');
|
||||||
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
|
||||||
var playlistId = tbody.getAttribute('data-playlist-id') || '';
|
var playlistId = tbody.getAttribute('data-playlist-id') || '';
|
||||||
|
var canvasWidth = Number(values.canvas_width);
|
||||||
|
var canvasHeight = Number(values.canvas_height);
|
||||||
|
var thumbnailStyle = '';
|
||||||
|
if (Number.isFinite(canvasWidth) && Number.isFinite(canvasHeight) && canvasWidth > 0 && canvasHeight > 0) {
|
||||||
|
thumbnailStyle = ' style="--playlist-slide-thumb-aspect-ratio: ' + canvasWidth + ' / ' + canvasHeight + ';"';
|
||||||
|
}
|
||||||
|
var thumbnailMarkup = values.thumbnail_path
|
||||||
|
? '<img class="playlist-slide-thumb-image" src="' + values.thumbnail_path + '" alt="" loading="lazy" />'
|
||||||
|
: '<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>';
|
||||||
row.setAttribute('data-playlist-slide-row', '');
|
row.setAttribute('data-playlist-slide-row', '');
|
||||||
row.setAttribute('data-row-key', rowKey);
|
row.setAttribute('data-row-key', rowKey);
|
||||||
row.setAttribute('data-slide-id', String(values.slide_id));
|
row.setAttribute('data-slide-id', String(values.slide_id));
|
||||||
@@ -541,7 +793,12 @@
|
|||||||
'<span class="playlist-order-number"></span>' +
|
'<span class="playlist-order-number"></span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
'<td>' + values.title + '<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
'<td data-label="Slide">' +
|
||||||
|
'<div class="playlist-slide-cell">' +
|
||||||
|
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||||
|
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
||||||
'<td>' +
|
'<td>' +
|
||||||
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
||||||
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
||||||
@@ -551,7 +808,7 @@
|
|||||||
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
||||||
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
||||||
'</td>' +
|
'</td>' +
|
||||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" /></td>' +
|
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" /></td>' +
|
||||||
'<td><div class="actions playlist-item-actions">' +
|
'<td><div class="actions playlist-item-actions">' +
|
||||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||||
@@ -609,32 +866,45 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (addSlideButton && addSlideSelect) {
|
if (addSlideModal && addSlideGrid) {
|
||||||
addSlideButton.addEventListener('click', function () {
|
renderSlidePicker();
|
||||||
var selectedOption = addSlideSelect.options[addSlideSelect.selectedIndex];
|
|
||||||
if (!selectedOption || selectedOption.disabled) {
|
addSlideGrid.addEventListener('click', function (event) {
|
||||||
|
var card = event.target.closest('.playlist-slide-picker-card');
|
||||||
|
if (!card) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
var placeholder = tbody.querySelector('.playlist-empty-row');
|
event.preventDefault();
|
||||||
if (placeholder) {
|
toggleCardSelection(card);
|
||||||
placeholder.remove();
|
});
|
||||||
}
|
|
||||||
var row = createRow({
|
if (addSlideSearch) {
|
||||||
row_key: 'new-' + Date.now(),
|
addSlideSearch.addEventListener('input', syncSlidePickerState);
|
||||||
slide_id: String(selectedOption.value),
|
}
|
||||||
canvas_signature: String(selectedOption.getAttribute('data-canvas-signature') || ''),
|
|
||||||
title: selectedOption.textContent || 'Slide',
|
if (addSlideShowAssigned) {
|
||||||
duration_seconds: 10,
|
addSlideShowAssigned.addEventListener('click', function () {
|
||||||
schedule_mode: 'always',
|
var nextState = addSlideShowAssigned.getAttribute('aria-pressed') !== 'true';
|
||||||
schedule_start_datetime: '',
|
setShowAssignedState(nextState);
|
||||||
schedule_end_datetime: '',
|
syncSlidePickerState();
|
||||||
schedule_start_time: '',
|
|
||||||
schedule_end_time: '',
|
|
||||||
schedule_days_json: '[]',
|
|
||||||
summary: 'Always visible'
|
|
||||||
});
|
});
|
||||||
tbody.appendChild(row);
|
}
|
||||||
updateRowOrder(true);
|
|
||||||
|
if (addSlideConfirm) {
|
||||||
|
addSlideConfirm.addEventListener('click', function () {
|
||||||
|
addSelectedSlides();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
addSlideModal.addEventListener('shown.bs.modal', function () {
|
||||||
|
syncSlidePickerState();
|
||||||
|
if (addSlideSearch) {
|
||||||
|
addSlideSearch.focus();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
addSlideModal.addEventListener('hidden.bs.modal', function () {
|
||||||
|
clearModalSelection();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -4,10 +4,137 @@
|
|||||||
var stateNode = document.getElementById('background-tasks-state');
|
var stateNode = document.getElementById('background-tasks-state');
|
||||||
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
var currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : '';
|
||||||
|
|
||||||
if (pathname !== '/settings/background-tasks') {
|
if (pathname !== '/settings/background-tasks' && pathname !== '/settings/background-tasks/scheduled') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getPageUrl(href) {
|
||||||
|
try {
|
||||||
|
return new URL(href, window.location.href);
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateLocalDateTimes(root) {
|
||||||
|
if (typeof window.initLocalDateTimes === 'function') {
|
||||||
|
window.initLocalDateTimes(root || document);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSectionFromDocument(sectionName, responseDocument) {
|
||||||
|
var currentCard = document.querySelector('[data-pagination-card="' + sectionName + '"]');
|
||||||
|
var nextCard = responseDocument.querySelector('[data-pagination-card="' + sectionName + '"]');
|
||||||
|
|
||||||
|
if (!currentCard || !nextCard) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentCard.outerHTML = nextCard.outerHTML;
|
||||||
|
updateLocalDateTimes(document.querySelector('[data-pagination-card="' + sectionName + '"]'));
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceSummaryFromDocument(responseDocument) {
|
||||||
|
var currentSummary = document.getElementById('background-tasks-summary');
|
||||||
|
var nextSummary = responseDocument.getElementById('background-tasks-summary');
|
||||||
|
|
||||||
|
if (!currentSummary || !nextSummary) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSummary.outerHTML = nextSummary.outerHTML;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceFiltersFromDocument(responseDocument) {
|
||||||
|
var currentFilters = document.getElementById('background-tasks-filters');
|
||||||
|
var nextFilters = responseDocument.getElementById('background-tasks-filters');
|
||||||
|
|
||||||
|
if (!currentFilters || !nextFilters) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentFilters.outerHTML = nextFilters.outerHTML;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function replaceStateFromDocument(responseDocument) {
|
||||||
|
var currentState = document.getElementById('background-tasks-state');
|
||||||
|
var nextState = responseDocument.getElementById('background-tasks-state');
|
||||||
|
|
||||||
|
if (!currentState || !nextState) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
currentState.outerHTML = nextState.outerHTML;
|
||||||
|
stateNode = document.getElementById('background-tasks-state');
|
||||||
|
currentVersion = stateNode ? String(stateNode.getAttribute('data-state-version') || '') : currentVersion;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPageResponse(responseDocument, sectionNames) {
|
||||||
|
var updated = false;
|
||||||
|
|
||||||
|
if (replaceStateFromDocument(responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceSummaryFromDocument(responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceFiltersFromDocument(responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionNames.forEach(function (sectionName) {
|
||||||
|
if (replaceSectionFromDocument(sectionName, responseDocument)) {
|
||||||
|
updated = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return updated;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadPage(nextUrl, options) {
|
||||||
|
var url = getPageUrl(nextUrl);
|
||||||
|
var sectionNames = options && options.sectionNames ? options.sectionNames : [];
|
||||||
|
var replaceHistory = Boolean(options && options.replaceHistory);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url.toString(), {
|
||||||
|
method: 'GET',
|
||||||
|
headers: {
|
||||||
|
'Accept': 'text/html, application/xhtml+xml',
|
||||||
|
'X-Requested-With': 'XMLHttpRequest'
|
||||||
|
},
|
||||||
|
credentials: 'same-origin',
|
||||||
|
cache: 'no-store'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Unable to load background tasks page.');
|
||||||
|
}
|
||||||
|
return response.text();
|
||||||
|
}).then(function (text) {
|
||||||
|
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
||||||
|
if (!applyPageResponse(responseDocument, sectionNames.length ? sectionNames : ['tasks', 'recurring'])) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (replaceHistory) {
|
||||||
|
window.history.replaceState({}, document.title, url.pathname + url.search + url.hash);
|
||||||
|
} else {
|
||||||
|
window.history.pushState({}, document.title, url.pathname + url.search + url.hash);
|
||||||
|
}
|
||||||
|
}).catch(function (_error) {
|
||||||
|
window.location.assign(url.pathname + url.search + url.hash);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function stripMessageParameter() {
|
function stripMessageParameter() {
|
||||||
try {
|
try {
|
||||||
var url = new URL(window.location.href);
|
var url = new URL(window.location.href);
|
||||||
@@ -21,6 +148,62 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function findPaginationLink(target) {
|
||||||
|
if (!target || !target.closest) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var link = target.closest('.pagination .page-link');
|
||||||
|
if (!link) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var href = String(link.getAttribute('href') || '').trim();
|
||||||
|
if (!href || href === '#') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = link.closest('[data-pagination-card]');
|
||||||
|
if (!card) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
href: href,
|
||||||
|
sectionName: String(card.getAttribute('data-pagination-card') || '').trim()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function initPaginationNavigation() {
|
||||||
|
document.addEventListener('click', function (event) {
|
||||||
|
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var paginationLink = findPaginationLink(event.target);
|
||||||
|
if (paginationLink && paginationLink.sectionName) {
|
||||||
|
var currentUrl = getPageUrl(window.location.href);
|
||||||
|
var nextUrl = getPageUrl(paginationLink.href);
|
||||||
|
if (!currentUrl || !nextUrl) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentUrl.pathname === nextUrl.pathname && currentUrl.search === nextUrl.search && currentUrl.hash === nextUrl.hash) {
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}, true);
|
||||||
|
|
||||||
|
window.addEventListener('popstate', function () {
|
||||||
|
loadPage(window.location.href, { replaceHistory: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function refreshPage() {
|
function refreshPage() {
|
||||||
if (document.visibilityState !== 'visible') {
|
if (document.visibilityState !== 'visible') {
|
||||||
return;
|
return;
|
||||||
@@ -59,5 +242,6 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
stripMessageParameter();
|
stripMessageParameter();
|
||||||
|
initPaginationNavigation();
|
||||||
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
window.setInterval(refreshPage, REFRESH_INTERVAL_MS);
|
||||||
}());
|
}());
|
||||||
@@ -543,7 +543,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (!src) {
|
if (!src) {
|
||||||
return '<div class="slide-preview-placeholder">Webpage</div>';
|
return '<div class="slide-preview-placeholder">Webpage</div>';
|
||||||
}
|
}
|
||||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPreviewHtmlRegion(value) {
|
function renderPreviewHtmlRegion(value) {
|
||||||
@@ -551,7 +551,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (!html) {
|
if (!html) {
|
||||||
return '<div class="slide-preview-placeholder">HTML</div>';
|
return '<div class="slide-preview-placeholder">HTML</div>';
|
||||||
}
|
}
|
||||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager"></iframe>';
|
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyEditors() {
|
function destroyEditors() {
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
var openPreviewPopupButton = document.getElementById('open-preview-popup');
|
||||||
var editorInstances = new Map();
|
var editorInstances = new Map();
|
||||||
var submitting = false;
|
var submitting = false;
|
||||||
|
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||||
|
var uploadMaxLabel = '100 MB';
|
||||||
var previewRenderFrame = 0;
|
var previewRenderFrame = 0;
|
||||||
var previewPopupWindow = null;
|
var previewPopupWindow = null;
|
||||||
var previewPopupRenderFrame = 0;
|
var previewPopupRenderFrame = 0;
|
||||||
@@ -67,6 +69,34 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
var sidebarTopOffset = 16;
|
var sidebarTopOffset = 16;
|
||||||
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
|
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
|
||||||
|
|
||||||
|
function applyBackdropStyle(element, backgroundColor, backgroundImagePath) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||||
|
element.style.backgroundColor = color;
|
||||||
|
element.style.backgroundImage = backgroundImagePath
|
||||||
|
? 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42)), url("' + encodeURI(String(backgroundImagePath)) + '")'
|
||||||
|
: 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||||
|
element.style.backgroundPosition = backgroundImagePath ? 'center, center' : 'center';
|
||||||
|
element.style.backgroundSize = backgroundImagePath ? '100% 100%, cover' : '100% 100%';
|
||||||
|
element.style.backgroundRepeat = backgroundImagePath ? 'no-repeat, no-repeat' : 'no-repeat';
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPopupBackdropStyle(element, backgroundColor) {
|
||||||
|
if (!element) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var color = String(backgroundColor || '#111111').trim() || '#111111';
|
||||||
|
element.style.backgroundColor = color;
|
||||||
|
element.style.backgroundImage = 'linear-gradient(rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.42))';
|
||||||
|
element.style.backgroundPosition = 'center';
|
||||||
|
element.style.backgroundSize = '100% 100%';
|
||||||
|
element.style.backgroundRepeat = 'no-repeat';
|
||||||
|
}
|
||||||
|
|
||||||
class FontSizeInputUI extends Plugin {
|
class FontSizeInputUI extends Plugin {
|
||||||
static get pluginName() {
|
static get pluginName() {
|
||||||
return 'FontSizeInputUI';
|
return 'FontSizeInputUI';
|
||||||
@@ -408,7 +438,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (!src) {
|
if (!src) {
|
||||||
return '<div class="slide-preview-placeholder">Webpage</div>';
|
return '<div class="slide-preview-placeholder">Webpage</div>';
|
||||||
}
|
}
|
||||||
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no"></iframe>';
|
return '<iframe class="slide-preview-webpage-frame" src="' + escapeHtml(src) + '" title="Webpage preview" loading="eager" referrerpolicy="no-referrer" scrolling="no" style="width:100%;height:100%;border:0;display:block;background:#fff;overflow:hidden;"></iframe>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderPreviewRtmpRegion(value, disableAudio) {
|
function renderPreviewRtmpRegion(value, disableAudio) {
|
||||||
@@ -425,7 +455,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (!html) {
|
if (!html) {
|
||||||
return '<div class="slide-preview-placeholder">HTML</div>';
|
return '<div class="slide-preview-placeholder">HTML</div>';
|
||||||
}
|
}
|
||||||
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager"></iframe>';
|
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
|
||||||
}
|
}
|
||||||
|
|
||||||
function destroyEditors() {
|
function destroyEditors() {
|
||||||
@@ -745,6 +775,333 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getRegionUploadZone(input) {
|
||||||
|
return input ? input.closest('[data-region-upload-zone]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadHiddenValue(input) {
|
||||||
|
var regionId = input && input.name ? input.name.replace(/^region_image_/, '') : '';
|
||||||
|
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
return hidden ? hidden.value : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadProgressNode(zone) {
|
||||||
|
return zone ? zone.querySelector('[data-region-upload-progress]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadProgressBar(zone) {
|
||||||
|
return zone ? zone.querySelector('[data-region-upload-progress-bar]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionUploadProgressText(zone) {
|
||||||
|
return zone ? zone.querySelector('[data-region-upload-progress-text]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegionUploadProgress(zone, percent, text, isIndeterminate) {
|
||||||
|
var progress = getRegionUploadProgressNode(zone);
|
||||||
|
var bar = getRegionUploadProgressBar(zone);
|
||||||
|
var progressText = getRegionUploadProgressText(zone);
|
||||||
|
var normalizedPercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
|
||||||
|
|
||||||
|
if (!progress || !bar) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (progressText) {
|
||||||
|
progressText.textContent = text || 'Uploading...';
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.hidden = false;
|
||||||
|
bar.style.width = (isIndeterminate ? 100 : normalizedPercent) + '%';
|
||||||
|
bar.textContent = isIndeterminate ? 'Uploading...' : normalizedPercent + '%';
|
||||||
|
bar.setAttribute('aria-valuenow', String(isIndeterminate ? 100 : normalizedPercent));
|
||||||
|
bar.classList.toggle('progress-bar-striped', Boolean(isIndeterminate));
|
||||||
|
bar.classList.toggle('progress-bar-animated', Boolean(isIndeterminate));
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegionUploadState(input, isUploading, percent, text, isIndeterminate) {
|
||||||
|
var zone = getRegionUploadZone(input);
|
||||||
|
var progress = getRegionUploadProgressNode(zone);
|
||||||
|
|
||||||
|
if (!zone || !progress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zone.classList.toggle('is-uploading', Boolean(isUploading));
|
||||||
|
zone.setAttribute('aria-busy', isUploading ? 'true' : 'false');
|
||||||
|
input.disabled = Boolean(isUploading);
|
||||||
|
|
||||||
|
if (isUploading) {
|
||||||
|
setRegionUploadProgress(zone, percent, text, isIndeterminate);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
progress.hidden = true;
|
||||||
|
if (input) {
|
||||||
|
input.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRegionImageFileWithinLimit(file) {
|
||||||
|
return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectOversizeRegionImage(input, file) {
|
||||||
|
var zone = getRegionUploadZone(input);
|
||||||
|
var hiddenValue = getRegionUploadHiddenValue(input);
|
||||||
|
|
||||||
|
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 = '';
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
renderImageRegionPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
|
||||||
|
if (file) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegionImageInputFile(input, file) {
|
||||||
|
if (!input || !file) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegionImageFileWithinLimit(file)) {
|
||||||
|
rejectOversizeRegionImage(input, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var dataTransfer = new DataTransfer();
|
||||||
|
dataTransfer.items.add(file);
|
||||||
|
input.files = dataTransfer.files;
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadRegionImageFile(input, file) {
|
||||||
|
if (!input || !file) {
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegionImageFileWithinLimit(file)) {
|
||||||
|
rejectOversizeRegionImage(input, file);
|
||||||
|
return Promise.resolve(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
var regionId = input.name.replace(/^region_image_/, '');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var zone = getRegionUploadZone(input);
|
||||||
|
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
|
||||||
|
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
|
||||||
|
var previousNeedsCleanup = String(input.dataset.uploadedNeedsCleanup || '') === '1';
|
||||||
|
|
||||||
|
input.dataset.uploadToken = uploadToken;
|
||||||
|
setRegionUploadState(input, true, 0, 'Uploading...', true);
|
||||||
|
|
||||||
|
return new Promise(function (resolve, reject) {
|
||||||
|
var xhr = new XMLHttpRequest();
|
||||||
|
var formData = new FormData();
|
||||||
|
formData.append('file', file, file.name || 'upload');
|
||||||
|
|
||||||
|
xhr.open('POST', '/slides/uploads', true);
|
||||||
|
xhr.responseType = 'text';
|
||||||
|
xhr.withCredentials = true;
|
||||||
|
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
|
||||||
|
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
|
||||||
|
|
||||||
|
xhr.upload.onprogress = function (event) {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!event || !event.lengthComputable || !event.total) {
|
||||||
|
setRegionUploadProgress(zone, 100, 'Uploading...', true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var percent = Math.round((event.loaded / event.total) * 100);
|
||||||
|
setRegionUploadProgress(zone, percent, 'Uploading ' + percent + '%', false);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onload = function () {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var response = {
|
||||||
|
ok: xhr.status >= 200 && xhr.status < 300,
|
||||||
|
status: xhr.status,
|
||||||
|
statusText: xhr.statusText,
|
||||||
|
responseText: xhr.responseText || ''
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
var error = new Error(response.responseText || 'Unable to upload image.');
|
||||||
|
error.status = response.status;
|
||||||
|
error.responseText = response.responseText;
|
||||||
|
reject(error);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = {};
|
||||||
|
try {
|
||||||
|
payload = JSON.parse(response.responseText || '{}') || {};
|
||||||
|
} catch (_error) {
|
||||||
|
payload = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
var uploadedPath = String(payload.path || '').trim();
|
||||||
|
if (!uploadedPath) {
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = uploadedPath;
|
||||||
|
}
|
||||||
|
input.dataset.uploadedPath = uploadedPath;
|
||||||
|
input.dataset.uploadedNeedsCleanup = '1';
|
||||||
|
input.value = '';
|
||||||
|
input.setCustomValidity('');
|
||||||
|
renderImageRegionPreview(card, uploadedPath);
|
||||||
|
templateSelectorLock.markEdited();
|
||||||
|
requestPreviewRender();
|
||||||
|
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
|
||||||
|
queueUploadCleanup([previousUploadedPath]);
|
||||||
|
}
|
||||||
|
setRegionUploadState(input, false);
|
||||||
|
resolve(payload);
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onerror = function () {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onabort = function () {
|
||||||
|
if (input.dataset.uploadToken !== uploadToken) {
|
||||||
|
resolve(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
reject(new Error('Unable to upload image.'));
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.onloadend = function () {
|
||||||
|
if (input.dataset.uploadToken === uploadToken) {
|
||||||
|
delete input.dataset.uploadToken;
|
||||||
|
setRegionUploadState(input, false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
xhr.send(formData);
|
||||||
|
}).catch(function (error) {
|
||||||
|
if (input.dataset.uploadToken === uploadToken) {
|
||||||
|
delete input.dataset.uploadToken;
|
||||||
|
setRegionUploadState(input, false);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPendingUploadCleanupPaths() {
|
||||||
|
return getRegionImageInputs().map(function (input) {
|
||||||
|
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return String(input.dataset.uploadedPath || '').trim();
|
||||||
|
}).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearPendingUploadCleanupPaths() {
|
||||||
|
getRegionImageInputs().forEach(function (input) {
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
delete input.dataset.uploadedNeedsCleanup;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function queueUploadCleanup(uploadPaths) {
|
||||||
|
var normalizedPaths = Array.from(new Set((uploadPaths || []).map(function (value) {
|
||||||
|
return String(value || '').trim();
|
||||||
|
}).filter(Boolean)));
|
||||||
|
|
||||||
|
if (!normalizedPaths.length) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var payload = JSON.stringify({ uploadPaths: normalizedPaths });
|
||||||
|
var url = '/slides/uploads/cleanup';
|
||||||
|
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
try {
|
||||||
|
navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' }));
|
||||||
|
return;
|
||||||
|
} catch (_error) {
|
||||||
|
// Fall back to fetch below.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
'Accept': 'application/json'
|
||||||
|
},
|
||||||
|
body: payload,
|
||||||
|
credentials: 'same-origin',
|
||||||
|
keepalive: true
|
||||||
|
}).catch(function () {
|
||||||
|
// Best-effort cleanup only.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderImageRegionPreview(card, value) {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var previewBox = card.querySelector('.slide-image-region-preview-box');
|
||||||
|
if (!previewBox) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
previewBox.innerHTML = value
|
||||||
|
? '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />'
|
||||||
|
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRegionImageInputs() {
|
||||||
|
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"]'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasPendingRegionImageUpload() {
|
||||||
|
return getRegionImageInputs().some(function (input) {
|
||||||
|
return Boolean(input && input.files && input.files.length);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
function getRegionBox(region, canvasWidth, canvasHeight) {
|
function getRegionBox(region, canvasWidth, canvasHeight) {
|
||||||
return {
|
return {
|
||||||
left: Math.max(0, Math.round(Number(region.x || 0))),
|
left: Math.max(0, Math.round(Number(region.x || 0))),
|
||||||
@@ -768,23 +1125,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<meta charset="utf-8" />' +
|
'<meta charset="utf-8" />' +
|
||||||
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
|
||||||
'<title>Slide preview</title>' +
|
'<title>Slide preview</title>' +
|
||||||
'<style>' +
|
'<link rel="stylesheet" href="/assets/css/theme-custom.css" />' +
|
||||||
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
|
|
||||||
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
|
|
||||||
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
|
|
||||||
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
|
|
||||||
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
|
|
||||||
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
|
|
||||||
'.slide-preview-overlay{z-index:2;}' +
|
|
||||||
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
|
|
||||||
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
|
|
||||||
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
|
|
||||||
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
|
|
||||||
'</style>' +
|
|
||||||
'</head>' +
|
'</head>' +
|
||||||
'<body>' +
|
'<body class="slide-preview-popup-body">' +
|
||||||
'<div id="popup-preview-stage">' +
|
'<div class="slide-preview-popup-stage" id="popup-preview-stage">' +
|
||||||
'<div id="popup-preview-canvas"></div>' +
|
'<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</body>' +
|
'</body>' +
|
||||||
'</html>';
|
'</html>';
|
||||||
@@ -814,7 +1159,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (!template) {
|
if (!template) {
|
||||||
popupStage.style.backgroundColor = '#111111';
|
applyBackdropStyle(popupCanvas, '#111111');
|
||||||
popupCanvas.style.width = '';
|
popupCanvas.style.width = '';
|
||||||
popupCanvas.style.height = '';
|
popupCanvas.style.height = '';
|
||||||
popupCanvas.style.transform = '';
|
popupCanvas.style.transform = '';
|
||||||
@@ -837,7 +1182,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
|
|
||||||
var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920));
|
var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920));
|
||||||
var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080));
|
var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080));
|
||||||
popupStage.style.backgroundColor = template.background_color || '#111111';
|
applyBackdropStyle(popupCanvas, template.background_color || '#111111', template.background_image_path);
|
||||||
popupCanvas.style.width = canvasWidth + 'px';
|
popupCanvas.style.width = canvasWidth + 'px';
|
||||||
popupCanvas.style.height = canvasHeight + 'px';
|
popupCanvas.style.height = canvasHeight + 'px';
|
||||||
popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
|
popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
|
||||||
@@ -949,7 +1294,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
slidePreviewOverlay.style.height = '';
|
slidePreviewOverlay.style.height = '';
|
||||||
slidePreviewOverlay.style.transform = '';
|
slidePreviewOverlay.style.transform = '';
|
||||||
slidePreviewStage.style.aspectRatio = '16 / 9';
|
slidePreviewStage.style.aspectRatio = '16 / 9';
|
||||||
slidePreviewStage.style.backgroundColor = '#111111';
|
applyBackdropStyle(slidePreviewStage, '#111111');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -959,7 +1304,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
currentPreviewCanvasWidth = canvasWidth;
|
currentPreviewCanvasWidth = canvasWidth;
|
||||||
currentPreviewCanvasHeight = canvasHeight;
|
currentPreviewCanvasHeight = canvasHeight;
|
||||||
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
|
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
|
||||||
slidePreviewStage.style.backgroundColor = template.background_color || '#111111';
|
applyBackdropStyle(slidePreviewStage, template.background_color || '#111111', template.background_image_path);
|
||||||
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
|
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
|
||||||
if (!(template.regions || []).length) {
|
if (!(template.regions || []).length) {
|
||||||
slidePreviewEmpty.style.display = 'flex';
|
slidePreviewEmpty.style.display = 'flex';
|
||||||
@@ -1090,7 +1435,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<span class="chip">Text</span>' +
|
'<span class="chip">Text</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
|
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1120,7 +1465,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<span class="chip">RSS</span>' +
|
'<span class="chip">RSS</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1165,7 +1510,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<span class="chip">API</span>' +
|
'<span class="chip">API</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
|
||||||
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current.value !== undefined ? current.value : '') + '</textarea>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1198,7 +1543,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">Webpage</span>' +
|
'<span class="chip">Webpage</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">Webpage URL' +
|
'<label style="display:block;">Webpage URL' +
|
||||||
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
@@ -1217,7 +1562,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">RTMP</span>' +
|
'<span class="chip">RTMP</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">RTMP URL' +
|
'<label style="display:block;">RTMP URL' +
|
||||||
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
|
'<input type="url" name="region_rtmp_' + region.id + '" class="form-control" value="' + escapeHtml(url) + '" placeholder="rtmp://example.com/live/stream" />' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
@@ -1238,7 +1583,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">HTML</span>' +
|
'<span class="chip">HTML</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">HTML content' +
|
'<label style="display:block;">HTML content' +
|
||||||
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
|
||||||
'</label>' +
|
'</label>' +
|
||||||
@@ -1247,22 +1592,62 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function reduceAspectRatio(width, height) {
|
||||||
|
var a = Math.max(1, Math.round(Number(width || 0) || 1));
|
||||||
|
var b = Math.max(1, Math.round(Number(height || 0) || 1));
|
||||||
|
while (b !== 0) {
|
||||||
|
var remainder = a % b;
|
||||||
|
a = b;
|
||||||
|
b = remainder;
|
||||||
|
}
|
||||||
|
var divisor = Math.max(1, a);
|
||||||
|
return Math.max(1, Math.round(Number(width || 0) || 1) / divisor) + ':' + Math.max(1, Math.round(Number(height || 0) || 1) / divisor);
|
||||||
|
}
|
||||||
|
|
||||||
function renderImageRegion(region) {
|
function renderImageRegion(region) {
|
||||||
var current = getCurrentRegionValue(region);
|
var current = getCurrentRegionValue(region);
|
||||||
|
var regionWidth = Math.max(1, Math.round(Number(region && region.width ? region.width : 0) || 1));
|
||||||
|
var regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||||
|
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
||||||
'<div class="card-header template-field-head">' +
|
'<div class="card-header template-field-head">' +
|
||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">Image</span>' +
|
'<span class="chip">Image</span>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'<div class="card-body p-3 d-grid gap-3">' +
|
'<div class="card-body p-3 d-grid">' +
|
||||||
'<label style="display:block;">Image file' +
|
'<div class="row g-3 align-items-start">' +
|
||||||
'<input type="file" name="region_image_' + region.id + '" class="form-control" accept="image/*" />' +
|
'<div class="col-12 col-md-8 d-flex flex-column">' +
|
||||||
'</label>' +
|
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
|
||||||
|
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="image/*" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
|
||||||
|
'<span class="slide-image-region-upload-zone-content">' +
|
||||||
|
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-copy">' +
|
||||||
|
'<strong>Drop an image here or click to upload</strong>' +
|
||||||
|
'<span>PNG, JPG, GIF, or WebP</span>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadMaxLabel + ' per file</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'</span>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
|
||||||
|
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
|
||||||
|
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
|
||||||
|
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</span>' +
|
||||||
|
'</label>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="col-12 col-md-4">' +
|
||||||
|
'<div class="slide-image-region-preview-box">' +
|
||||||
|
(current
|
||||||
|
? '<div class="slide-image-region-preview-shell" data-remove-region-image="' + region.id + '" role="button" tabindex="0" aria-label="Remove image">' +
|
||||||
|
'<img class="slide-image-region-preview" src="' + escapeHtml(current) + '" alt="Current image preview" />' +
|
||||||
|
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
|
||||||
|
'</div>'
|
||||||
|
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>') +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
|
||||||
(current
|
|
||||||
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
|
|
||||||
: '<div class="muted slide-image-file">No image selected.</div>') +
|
|
||||||
'</div>' +
|
'</div>' +
|
||||||
'</div>';
|
'</div>';
|
||||||
}
|
}
|
||||||
@@ -1409,15 +1794,151 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
|
|
||||||
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
|
||||||
input.addEventListener('change', function () {
|
input.addEventListener('change', function () {
|
||||||
|
var regionId = input.name.replace(/^region_image_/, '');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var file = input.files && input.files.length ? input.files[0] : null;
|
||||||
|
|
||||||
|
if (file && !isRegionImageFileWithinLimit(file)) {
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
if (typeof window.showToast === 'function') {
|
||||||
|
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
|
||||||
|
} else {
|
||||||
|
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
|
||||||
|
}
|
||||||
|
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (input.dataset.previewUrl) {
|
if (input.dataset.previewUrl) {
|
||||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
delete input.dataset.previewUrl;
|
delete input.dataset.previewUrl;
|
||||||
}
|
}
|
||||||
if (input.files && input.files.length) {
|
if (file) {
|
||||||
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
|
input.dataset.previewUrl = URL.createObjectURL(file);
|
||||||
}
|
}
|
||||||
|
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
|
||||||
templateSelectorLock.markEdited();
|
templateSelectorLock.markEdited();
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
|
if (file) {
|
||||||
|
uploadRegionImageFile(input, file).catch(function (error) {
|
||||||
|
if (input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
input.value = '';
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(error && error.message ? error.message : 'Unable to upload image.', 'danger');
|
||||||
|
} else {
|
||||||
|
window.alert(error && error.message ? error.message : 'Unable to upload image.');
|
||||||
|
}
|
||||||
|
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
||||||
|
requestPreviewRender();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
|
||||||
|
var regionId = zone.getAttribute('data-region-upload-zone');
|
||||||
|
var input = zone.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||||
|
if (!input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
zone.addEventListener('dragover', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.add('is-dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('dragleave', function () {
|
||||||
|
zone.classList.remove('is-dragover');
|
||||||
|
});
|
||||||
|
|
||||||
|
zone.addEventListener('drop', function (event) {
|
||||||
|
event.preventDefault();
|
||||||
|
zone.classList.remove('is-dragover');
|
||||||
|
|
||||||
|
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
|
||||||
|
var file = files && files.length ? files[0] : null;
|
||||||
|
if (!file || !/^image\//i.test(file.type || '')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRegionImageFileWithinLimit(file)) {
|
||||||
|
rejectOversizeRegionImage(input, file);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setRegionImageInputFile(input, file);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
templateFields.querySelectorAll('button[data-remove-region-image]').forEach(function (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
var regionId = button.getAttribute('data-remove-region-image');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||||
|
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||||
|
|
||||||
|
if (input && input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
||||||
|
queueUploadCleanup([uploadPath]);
|
||||||
|
}
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
delete input.dataset.uploadedPath;
|
||||||
|
delete input.dataset.uploadedNeedsCleanup;
|
||||||
|
}
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderImageRegionPreview(card, '');
|
||||||
|
templateSelectorLock.markEdited();
|
||||||
|
requestPreviewRender();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
|
||||||
|
shell.addEventListener('click', function () {
|
||||||
|
var regionId = shell.getAttribute('data-remove-region-image');
|
||||||
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
|
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
||||||
|
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
||||||
|
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||||
|
|
||||||
|
if (input && input.dataset.previewUrl) {
|
||||||
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
|
delete input.dataset.previewUrl;
|
||||||
|
}
|
||||||
|
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
||||||
|
queueUploadCleanup([uploadPath]);
|
||||||
|
}
|
||||||
|
if (input) {
|
||||||
|
input.value = '';
|
||||||
|
delete input.dataset.uploadedPath;
|
||||||
|
delete input.dataset.uploadedNeedsCleanup;
|
||||||
|
}
|
||||||
|
if (hidden) {
|
||||||
|
hidden.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
renderImageRegionPreview(card, '');
|
||||||
|
templateSelectorLock.markEdited();
|
||||||
|
requestPreviewRender();
|
||||||
|
});
|
||||||
|
|
||||||
|
shell.addEventListener('keydown', function (event) {
|
||||||
|
if (event.key === 'Enter' || event.key === ' ') {
|
||||||
|
event.preventDefault();
|
||||||
|
shell.click();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||||
@@ -1485,6 +2006,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
});
|
});
|
||||||
submitting = true;
|
submitting = true;
|
||||||
try {
|
try {
|
||||||
|
if (hasPendingRegionImageUpload()) {
|
||||||
|
throw new Error('Wait for the image upload to finish before saving.');
|
||||||
|
}
|
||||||
await Promise.all(saves);
|
await Promise.all(saves);
|
||||||
var formData = new FormData(slideForm);
|
var formData = new FormData(slideForm);
|
||||||
if (event.submitter && event.submitter.name) {
|
if (event.submitter && event.submitter.name) {
|
||||||
@@ -1515,6 +2039,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (isCreateForm) {
|
if (isCreateForm) {
|
||||||
try {
|
try {
|
||||||
if (response.url) {
|
if (response.url) {
|
||||||
|
clearPendingUploadCleanupPaths();
|
||||||
window.location.replace(response.url);
|
window.location.replace(response.url);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1540,6 +2065,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
|
|
||||||
showToast(savedMessage || 'Saved slide.', 'success');
|
showToast(savedMessage || 'Saved slide.', 'success');
|
||||||
slideForm.dataset.dirty = 'false';
|
slideForm.dataset.dirty = 'false';
|
||||||
|
clearPendingUploadCleanupPaths();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
||||||
@@ -1590,6 +2116,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
window.addEventListener('resize', requestPreviewRender);
|
window.addEventListener('resize', requestPreviewRender);
|
||||||
window.addEventListener('resize', requestSidebarSync);
|
window.addEventListener('resize', requestSidebarSync);
|
||||||
window.addEventListener('beforeunload', function () {
|
window.addEventListener('beforeunload', function () {
|
||||||
|
queueUploadCleanup(getPendingUploadCleanupPaths());
|
||||||
if (previewPopupWindow && !previewPopupWindow.closed) {
|
if (previewPopupWindow && !previewPopupWindow.closed) {
|
||||||
previewPopupWindow.close();
|
previewPopupWindow.close();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}());
|
||||||
@@ -114,6 +114,10 @@
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -528,10 +528,10 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -701,8 +701,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);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,20 +8,59 @@ 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;
|
||||||
|
|
||||||
|
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 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 +97,12 @@ 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 data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderSlidesPage({
|
||||||
|
slides: data.slides || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -119,6 +162,37 @@ 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.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
const payload = await common.buildSlidePayload(pool, req, null);
|
const payload = await common.buildSlidePayload(pool, req, null);
|
||||||
@@ -136,6 +210,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',
|
||||||
@@ -176,6 +253,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 +298,12 @@ 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 data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderTemplatesPage({
|
||||||
|
templates: data.templates || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -355,8 +439,12 @@ 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 data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderCanvasSizesPage({
|
||||||
|
canvasSizes: data.canvasSizes || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, 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,21 @@ 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 data = await common.fetchApiSourcesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
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', {}, 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 +380,12 @@ 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 data = await common.fetchRssFeedsPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderRssFeedsPage({
|
||||||
|
rssFeeds: data.rssFeeds || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, 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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -76,6 +76,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 +84,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 +100,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.');
|
||||||
}
|
}
|
||||||
@@ -220,7 +222,7 @@ 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];
|
||||||
|
|||||||
@@ -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,12 @@ 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 data = await rbacData.fetchRolesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderRbacPage({
|
||||||
|
roles: data.roles || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, 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 +369,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,9 @@ 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 data = await rbacData.fetchUsersWithRolesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
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 +66,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', {}, 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,10 @@
|
|||||||
const { renderView } = require('../../view');
|
const { renderView } = require('../../view');
|
||||||
const { hasAnyPermission } = require('../../../rbac');
|
const { hasAnyPermission } = require('../../../rbac');
|
||||||
|
const { buildPaginationPages } = require('../../lib/pagination');
|
||||||
|
|
||||||
const TASKS_PER_PAGE = 10;
|
const TASKS_PER_PAGE = 10;
|
||||||
|
const QUEUE_PAGE_URL = '/settings/background-tasks';
|
||||||
|
const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled';
|
||||||
|
|
||||||
function formatIntervalLabel(intervalMs) {
|
function formatIntervalLabel(intervalMs) {
|
||||||
const value = Math.max(1, Number(intervalMs) || 0);
|
const value = Math.max(1, Number(intervalMs) || 0);
|
||||||
@@ -21,21 +24,44 @@ function parsePageNumber(value) {
|
|||||||
return Math.max(1, pageNumber);
|
return Math.max(1, pageNumber);
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPagination(totalItems, currentPage, pageParam) {
|
function normalizeSourceFilter(sourceType, sourceId) {
|
||||||
|
const normalizedSourceType = String(sourceType || '').trim();
|
||||||
|
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
|
||||||
|
|
||||||
|
if (!normalizedSourceType || !Number.isFinite(normalizedSourceId) || normalizedSourceId <= 0) {
|
||||||
|
return { sourceType: '', sourceId: '' };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
sourceType: normalizedSourceType,
|
||||||
|
sourceId: normalizedSourceId
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildQueryString(query) {
|
||||||
|
const searchParams = new URLSearchParams();
|
||||||
|
['page', 'recurringPage', 'status', 'sourceType', 'sourceId'].forEach(function (key) {
|
||||||
|
const value = query && query[key];
|
||||||
|
if (value === undefined || value === null || value === '') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
searchParams.set(key, String(value));
|
||||||
|
});
|
||||||
|
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
return queryString ? `?${queryString}` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPagination(totalItems, currentPage, pageParam, queryState) {
|
||||||
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
const normalizedPageParam = String(pageParam || 'page').trim() || 'page';
|
||||||
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
|
const totalPages = Math.max(1, Math.ceil(totalItems / TASKS_PER_PAGE));
|
||||||
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
|
||||||
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
|
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
|
||||||
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
|
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
|
||||||
const pages = [];
|
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
|
||||||
|
|
||||||
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
|
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
|
||||||
pages.push({
|
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
|
||||||
number: pageNumber,
|
|
||||||
active: pageNumber === safeCurrentPage,
|
|
||||||
url: `?${normalizedPageParam}=${pageNumber}`
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
currentPage: safeCurrentPage,
|
currentPage: safeCurrentPage,
|
||||||
@@ -46,38 +72,141 @@ function buildPagination(totalItems, currentPage, pageParam) {
|
|||||||
endItem: endIndex,
|
endItem: endIndex,
|
||||||
hasPrevious: safeCurrentPage > 1,
|
hasPrevious: safeCurrentPage > 1,
|
||||||
hasNext: safeCurrentPage < totalPages,
|
hasNext: safeCurrentPage < totalPages,
|
||||||
previousUrl: `?${normalizedPageParam}=${safeCurrentPage - 1}`,
|
previousUrl: buildQueryString(previousQuery),
|
||||||
nextUrl: `?${normalizedPageParam}=${safeCurrentPage + 1}`,
|
nextUrl: buildQueryString(nextQuery),
|
||||||
pages: pages,
|
pages: pages,
|
||||||
pageSize: TASKS_PER_PAGE,
|
pageSize: TASKS_PER_PAGE,
|
||||||
pageParam: normalizedPageParam
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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 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 queryState = {
|
||||||
const recurringPagination = buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage');
|
page: parsePageNumber(data && data.page),
|
||||||
const visibleTasks = tasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
recurringPage: parsePageNumber(data && data.recurringPage),
|
||||||
|
sourceType: '',
|
||||||
|
sourceId: ''
|
||||||
|
};
|
||||||
|
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);
|
||||||
|
});
|
||||||
|
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
|
||||||
|
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
|
||||||
|
const visibleTasks = filteredTasks.slice((pagination.currentPage - 1) * TASKS_PER_PAGE, pagination.currentPage * TASKS_PER_PAGE);
|
||||||
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.currentPage * TASKS_PER_PAGE);
|
const visibleRecurringTasks = recurringTasks.slice((recurringPagination.currentPage - 1) * TASKS_PER_PAGE, recurringPagination.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) : '',
|
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||||
tasks: visibleTasks,
|
backgroundTasksMenuOpen: true,
|
||||||
recurringTasks: visibleRecurringTasks.map(function (task) {
|
tasks: visibleTasksWithSources,
|
||||||
return Object.assign({}, task, {
|
|
||||||
intervalLabel: formatIntervalLabel(task.intervalMs)
|
|
||||||
});
|
|
||||||
}),
|
|
||||||
summary: summary,
|
summary: summary,
|
||||||
|
statusCards: statusCards,
|
||||||
|
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
|
||||||
|
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
|
||||||
|
hasActiveFilters: hasActiveFilters,
|
||||||
pagination: pagination,
|
pagination: pagination,
|
||||||
recurringPagination: recurringPagination,
|
recurringPagination: recurringPagination,
|
||||||
canManage: canManage,
|
canManage: canManage,
|
||||||
@@ -85,4 +214,38 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
|
|||||||
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']);
|
||||||
|
|
||||||
|
return renderView('settings/tasks-scheduled/index', {
|
||||||
|
title: 'Scheduled tasks',
|
||||||
|
active: 'background-tasks-scheduled',
|
||||||
|
message: message,
|
||||||
|
currentUser: currentUser || null,
|
||||||
|
scripts: ['js/settings/background-tasks.js'],
|
||||||
|
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
|
||||||
|
backgroundTasksMenuOpen: true,
|
||||||
|
recurringTasks: recurringTasks.map(function (task) {
|
||||||
|
return Object.assign({}, task, {
|
||||||
|
intervalLabel: formatIntervalLabel(task.intervalMs)
|
||||||
|
});
|
||||||
|
}),
|
||||||
|
summary: summary,
|
||||||
|
canManage: canManage,
|
||||||
|
scheduledCount: Number(summary.scheduledCount || recurringTasks.length || 0),
|
||||||
|
recurringPagination: buildPagination(recurringTasks.length, parsePageNumber(data && data.recurringPage), 'recurringPage', { recurringPage: parsePageNumber(data && data.recurringPage) })
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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;
|
||||||
@@ -50,7 +67,28 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
res.send(pages.renderBackgroundTasksPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, 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/background-tasks/scheduled', requireScheduledSettingsAccess(), function (req, res) {
|
||||||
|
const tasks = backgroundTaskQueue.listTasks();
|
||||||
|
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||||
|
const summary = backgroundTaskQueue.getSummary();
|
||||||
|
const version = buildQueueVersion(tasks, recurringTasks, summary);
|
||||||
|
res.send(pages.renderBackgroundTasksScheduledPage({ tasks: tasks, recurringTasks: recurringTasks, summary: summary, stateVersion: version, page: req.query.page, recurringPage: req.query.recurringPage }, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/settings/background-tasks/state', function (req, res, next) {
|
||||||
|
if (!req.currentUser) {
|
||||||
|
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasAnyPermission(req.currentUser, ['background-tasks.read', 'background-tasks.allow', 'scheduled-tasks.read', 'scheduled-tasks.allow'])) {
|
||||||
|
const error = new Error('You do not have permission to access this area.');
|
||||||
|
error.statusCode = 403;
|
||||||
|
error.expose = true;
|
||||||
|
return next(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
return next();
|
||||||
|
}, function (req, res) {
|
||||||
const tasks = backgroundTaskQueue.listTasks();
|
const tasks = backgroundTaskQueue.listTasks();
|
||||||
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
const recurringTasks = backgroundTaskQueue.listRecurringTasks();
|
||||||
const summary = backgroundTaskQueue.getSummary();
|
const summary = backgroundTaskQueue.getSummary();
|
||||||
@@ -96,4 +134,22 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
|
|||||||
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
|
||||||
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
app.post('/settings/background-tasks/recurring/:key/run', function (req, res, next) {
|
||||||
|
if (!req.currentUser) {
|
||||||
|
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();
|
||||||
|
}, function (req, res) {
|
||||||
|
const ran = backgroundTaskQueue.runRecurringTask(String(req.params.key || '').trim());
|
||||||
|
res.redirect('/settings/background-tasks/scheduled?message=' + encodeURIComponent(ran ? 'Scheduled refresh run queued.' : 'Unable to run that scheduled refresh.'));
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,19 @@ 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 data = await common.fetchCanvasSizesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderCanvasSizesPage({
|
||||||
|
canvasSizes: data.canvasSizes || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, 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
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -2,12 +2,22 @@ module.exports = function registerClientsRoutes(app, deps) {
|
|||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
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;
|
||||||
|
|
||||||
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 totalItems = Array.isArray(data.clients) ? data.clients.length : 0;
|
||||||
|
const startIndex = (page - 1) * LIST_PAGE_SIZE;
|
||||||
|
const clients = (data.clients || []).slice(startIndex, startIndex + LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderConnectedClientsPage({
|
||||||
|
clients: clients,
|
||||||
|
pagination: buildPagination(totalItems, page, 'page', {}, 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
|
||||||
|
|||||||
@@ -102,6 +102,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 +111,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', 'js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,20 @@ 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));
|
||||||
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);
|
||||||
|
res.send(pages.renderPlaylistsPage({
|
||||||
|
playlists: data.playlists || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, 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;
|
||||||
@@ -14,8 +17,8 @@ module.exports = function registerScreensRoutes(app, deps) {
|
|||||||
|
|
||||||
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 data = await buildDashboardState(pool);
|
||||||
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
|
||||||
if (!screen) {
|
if (!screen) {
|
||||||
return res.status(404).send('Screen not found');
|
return res.status(404).send('Screen not found');
|
||||||
@@ -23,7 +26,12 @@ 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 data = await common.fetchScreensPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderScreensPage({
|
||||||
|
screens: data.screens || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, 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,19 @@ 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 data = await common.fetchSlidesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderSlidesPage({
|
||||||
|
slides: data.slides || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'slides', 'Slide pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -50,6 +50,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,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
|||||||
name: template.name,
|
name: template.name,
|
||||||
canvas_size_width: template.canvas_size_width,
|
canvas_size_width: template.canvas_size_width,
|
||||||
canvas_size_height: template.canvas_size_height,
|
canvas_size_height: template.canvas_size_height,
|
||||||
regionCount: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
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 +16,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,19 @@ 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 data = await common.fetchTemplatesPage(pool, page, LIST_PAGE_SIZE);
|
||||||
|
res.send(pages.renderTemplatesPage({
|
||||||
|
templates: data.templates || [],
|
||||||
|
pagination: buildPagination(data.totalItems, data.currentPage, 'page', {}, LIST_PAGE_SIZE, 'templates', 'Template pages')
|
||||||
|
}, req.query.message ? String(req.query.message) : '', req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ Handlebars.registerHelper('saveActionButtons', function (options) {
|
|||||||
].join(''));
|
].join(''));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Handlebars.registerPartial('modal-shell', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'modal-shell.hbs'), 'utf8'));
|
||||||
|
Handlebars.registerPartial('table-pagination', fs.readFileSync(path.join(VIEWS_ROOT, 'shared', 'table-pagination.hbs'), 'utf8'));
|
||||||
|
|
||||||
function resolveTemplatePath(relativePath) {
|
function resolveTemplatePath(relativePath) {
|
||||||
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
|
const firstSegment = String(relativePath || '').split(/[\\/]/)[0];
|
||||||
if (SIGNAGE_VIEW_PREFIXES.has(firstSegment)) {
|
if (SIGNAGE_VIEW_PREFIXES.has(firstSegment)) {
|
||||||
|
|||||||
@@ -5,17 +5,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Saved sources</h3>
|
<h3 class="card-title">Saved sources</h3>
|
||||||
{{#if (hasPermission currentUser 'api-sources.create')}}
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
<div class="card-tools">
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
<a class="btn btn-primary btn-sm" href="/data-sources/api-sources/new">Add API source</a>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search API sources" aria-label="Search API sources" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{#if (hasPermission currentUser 'api-sources.create')}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="/data-sources/api-sources/new">Add API source</a>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table class="table table-striped w-100 mb-0">
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -29,7 +33,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#if apiSources.length}}
|
{{#if apiSources.length}}
|
||||||
{{#each apiSources}}
|
{{#each apiSources}}
|
||||||
<tr>
|
<tr data-table-search-row>
|
||||||
<td data-label="Name">{{name}}</td>
|
<td data-label="Name">{{name}}</td>
|
||||||
<td data-label="API URL" class="text-break">{{api_url}}</td>
|
<td data-label="API URL" class="text-break">{{api_url}}</td>
|
||||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||||
@@ -69,9 +73,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="6" class="empty">No API sources yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="6" class="empty">No API sources yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/data-sources/api-sources"}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,17 +5,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Saved feeds</h3>
|
<h3 class="card-title">Saved feeds</h3>
|
||||||
{{#if (hasPermission currentUser 'rss-feeds.create')}}
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
<div class="card-tools">
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
<a class="btn btn-primary btn-sm" href="/data-sources/rss-feeds/new">Add RSS feed</a>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search RSS feeds" aria-label="Search RSS feeds" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{#if (hasPermission currentUser 'rss-feeds.create')}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="/data-sources/rss-feeds/new">Add RSS feed</a>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table class="table table-striped w-100 mb-0">
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -28,7 +32,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#if rssFeeds.length}}
|
{{#if rssFeeds.length}}
|
||||||
{{#each rssFeeds}}
|
{{#each rssFeeds}}
|
||||||
<tr>
|
<tr data-table-search-row>
|
||||||
<td data-label="Name">{{name}}</td>
|
<td data-label="Name">{{name}}</td>
|
||||||
<td data-label="Feed URL" class="text-break">{{feed_url}}</td>
|
<td data-label="Feed URL" class="text-break">{{feed_url}}</td>
|
||||||
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
<td data-label="Refresh interval">{{intervalLabel}}</td>
|
||||||
@@ -52,9 +56,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="5" class="empty">No RSS feeds yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="5" class="empty">No RSS feeds yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/data-sources/rss-feeds"}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,264 +0,0 @@
|
|||||||
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
|
||||||
|
|
||||||
<div class="page-header">
|
|
||||||
<div>
|
|
||||||
<h2>Background tasks</h2>
|
|
||||||
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row g-3 mb-4">
|
|
||||||
<div class="col-6 col-xl-3">
|
|
||||||
<div class="card card-outline card-primary h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase">Queued</div>
|
|
||||||
<div class="fs-3 fw-semibold">{{summary.counts.queued}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-xl-3">
|
|
||||||
<div class="card card-outline card-info h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase">Running</div>
|
|
||||||
<div class="fs-3 fw-semibold">{{summary.counts.running}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-xl-3">
|
|
||||||
<div class="card card-outline card-danger h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase">Failed</div>
|
|
||||||
<div class="fs-3 fw-semibold">{{summary.counts.failed}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="col-6 col-xl-3">
|
|
||||||
<div class="card card-outline card-success h-100">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="text-muted small text-uppercase">Finished</div>
|
|
||||||
<div class="fs-3 fw-semibold">{{summary.counts.completed}}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card card-outline card-primary mb-4">
|
|
||||||
<div class="card-header d-flex align-items-center gap-2">
|
|
||||||
<h3 class="card-title mb-0">Task queue</h3>
|
|
||||||
<div class="ms-auto d-flex align-items-center gap-2">
|
|
||||||
{{#if canManage}}
|
|
||||||
{{#if canClearFinished}}
|
|
||||||
<form method="post" action="/settings/background-tasks/clear-finished">
|
|
||||||
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
|
||||||
</form>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
<span class="text-muted small">Active workers: {{summary.activeCount}}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-body table-responsive p-0">
|
|
||||||
<table class="table table-striped w-100 mb-0 align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Task</th>
|
|
||||||
<th>Category</th>
|
|
||||||
<th>Status</th>
|
|
||||||
<th>Queued</th>
|
|
||||||
<th>Started</th>
|
|
||||||
<th>Finished</th>
|
|
||||||
<th>Source</th>
|
|
||||||
<th>Actions</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{#if tasks.length}}
|
|
||||||
{{#each tasks}}
|
|
||||||
<tr>
|
|
||||||
<td data-label="Task">
|
|
||||||
<div class="fw-semibold">{{title}}</div>
|
|
||||||
{{#if key}}
|
|
||||||
<div class="text-muted small text-break">{{key}}</div>
|
|
||||||
{{/if}}
|
|
||||||
{{#if errorMessage}}
|
|
||||||
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Category">{{category}}</td>
|
|
||||||
<td data-label="Status">
|
|
||||||
{{#if (eq status 'queued')}}
|
|
||||||
<span class="badge text-bg-secondary">Queued</span>
|
|
||||||
{{else if (eq status 'running')}}
|
|
||||||
<span class="badge text-bg-primary">Running</span>
|
|
||||||
{{else if (eq status 'completed')}}
|
|
||||||
<span class="badge text-bg-success">Completed</span>
|
|
||||||
{{else if (eq status 'failed')}}
|
|
||||||
<span class="badge text-bg-danger">Failed</span>
|
|
||||||
{{else if (eq status 'canceled')}}
|
|
||||||
<span class="badge text-bg-warning">Canceled</span>
|
|
||||||
{{else}}
|
|
||||||
<span class="badge text-bg-light text-dark">{{status}}</span>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Queued">
|
|
||||||
{{#if createdAt}}
|
|
||||||
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
|
|
||||||
{{else}}
|
|
||||||
-
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Started">
|
|
||||||
{{#if startedAt}}
|
|
||||||
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
|
|
||||||
{{else}}
|
|
||||||
-
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Finished">
|
|
||||||
{{#if finishedAt}}
|
|
||||||
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
|
|
||||||
{{else}}
|
|
||||||
-
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Source">
|
|
||||||
{{#if metadata.sourceName}}
|
|
||||||
<div>{{metadata.sourceName}}</div>
|
|
||||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
|
||||||
{{else}}
|
|
||||||
<span class="empty">-</span>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Actions">
|
|
||||||
<div class="d-flex flex-wrap gap-2">
|
|
||||||
{{#if canManage}}
|
|
||||||
{{#if (eq status 'queued')}}
|
|
||||||
<form method="post" action="/settings/background-tasks/{{id}}/cancel">
|
|
||||||
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm-message="Cancel this queued task?">Cancel</button>
|
|
||||||
</form>
|
|
||||||
{{/if}}
|
|
||||||
{{#if (eq status 'failed')}}
|
|
||||||
<form method="post" action="/settings/background-tasks/{{id}}/retry">
|
|
||||||
<button type="submit" class="btn btn-outline-primary btn-sm">Retry</button>
|
|
||||||
</form>
|
|
||||||
{{/if}}
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
{{else}}
|
|
||||||
<tr>
|
|
||||||
<td colspan="8" class="empty">No background tasks are queued right now.</td>
|
|
||||||
</tr>
|
|
||||||
{{/if}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{#if pagination.hasMultiplePages}}
|
|
||||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
|
||||||
<div class="text-muted small">
|
|
||||||
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
|
|
||||||
</div>
|
|
||||||
<nav aria-label="Background task pages">
|
|
||||||
<ul class="pagination pagination-sm mb-0">
|
|
||||||
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
|
||||||
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
|
||||||
</li>
|
|
||||||
{{#each pagination.pages}}
|
|
||||||
<li class="page-item {{#if active}}active{{/if}}">
|
|
||||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
|
||||||
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="card card-outline card-secondary">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title mb-0">Scheduled refreshes</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body table-responsive p-0">
|
|
||||||
<table class="table table-striped w-100 mb-0 align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Task</th>
|
|
||||||
<th>Interval</th>
|
|
||||||
<th>Next run</th>
|
|
||||||
<th>Last run</th>
|
|
||||||
<th>Last result</th>
|
|
||||||
<th>Source</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{{#if recurringTasks.length}}
|
|
||||||
{{#each recurringTasks}}
|
|
||||||
<tr>
|
|
||||||
<td data-label="Task">
|
|
||||||
<div class="fw-semibold">{{title}}</div>
|
|
||||||
<div class="text-muted small text-break">{{key}}</div>
|
|
||||||
</td>
|
|
||||||
<td data-label="Interval">{{intervalLabel}}</td>
|
|
||||||
<td data-label="Next run">
|
|
||||||
{{#if nextRunAt}}
|
|
||||||
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
|
||||||
{{else}}-{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Last run">
|
|
||||||
{{#if lastRunAt}}
|
|
||||||
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
|
||||||
{{else}}-{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Last result">
|
|
||||||
{{#if lastStatus}}
|
|
||||||
<span class="badge {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
|
|
||||||
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
|
|
||||||
{{else}}
|
|
||||||
<span class="empty">-</span>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
<td data-label="Source">
|
|
||||||
{{#if metadata.sourceName}}
|
|
||||||
<div>{{metadata.sourceName}}</div>
|
|
||||||
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
|
||||||
{{else}}
|
|
||||||
<span class="empty">-</span>
|
|
||||||
{{/if}}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
{{/each}}
|
|
||||||
{{else}}
|
|
||||||
<tr>
|
|
||||||
<td colspan="6" class="empty">No scheduled refreshes are registered yet.</td>
|
|
||||||
</tr>
|
|
||||||
{{/if}}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
{{#if recurringPagination.hasMultiplePages}}
|
|
||||||
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
|
||||||
<div class="text-muted small">
|
|
||||||
Showing {{recurringPagination.startItem}}-{{recurringPagination.endItem}} of {{recurringPagination.totalItems}} scheduled refreshes
|
|
||||||
</div>
|
|
||||||
<nav aria-label="Scheduled refresh pages">
|
|
||||||
<ul class="pagination pagination-sm mb-0">
|
|
||||||
<li class="page-item {{#unless recurringPagination.hasPrevious}}disabled{{/unless}}">
|
|
||||||
<a class="page-link" href="{{#if recurringPagination.hasPrevious}}/settings/background-tasks{{recurringPagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
|
||||||
</li>
|
|
||||||
{{#each recurringPagination.pages}}
|
|
||||||
<li class="page-item {{#if active}}active{{/if}}">
|
|
||||||
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
|
||||||
</li>
|
|
||||||
{{/each}}
|
|
||||||
<li class="page-item {{#unless recurringPagination.hasNext}}disabled{{/unless}}">
|
|
||||||
<a class="page-link" href="{{#if recurringPagination.hasNext}}/settings/background-tasks{{recurringPagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
@@ -7,17 +7,21 @@
|
|||||||
|
|
||||||
<div class="row g-4">
|
<div class="row g-4">
|
||||||
<div class="col-12">
|
<div class="col-12">
|
||||||
<div class="card card-outline card-primary admin-form-card">
|
<div class="card card-outline card-primary admin-form-card" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Existing roles</h3>
|
<h3 class="card-title">Existing roles</h3>
|
||||||
{{#if (hasPermission currentUser 'rbac.create')}}
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
<div class="card-tools">
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
<a class="btn btn-primary btn-sm" href="/rbac/new">Add role</a>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search roles" aria-label="Search roles" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{#if (hasPermission currentUser 'rbac.create')}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="/rbac/new">Add role</a>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table class="table table-striped w-100 mb-0 users-table">
|
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Role</th>
|
<th>Role</th>
|
||||||
@@ -30,11 +34,11 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#if roles.length}}
|
{{#if roles.length}}
|
||||||
{{#each roles}}
|
{{#each roles}}
|
||||||
<tr>
|
<tr data-table-search-row>
|
||||||
<td data-label="Role">
|
<td data-label="Role">
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
<div>
|
<div>
|
||||||
<strong>{{name}}</strong>
|
{{name}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -68,11 +72,12 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="5" class="empty">No roles found.</td></tr>
|
<tr data-table-search-empty-default><td colspan="5" class="empty">No roles found.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/rbac"}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>Background tasks</h2>
|
||||||
|
<p>Queued data refreshes run in the web process. Finished items stay here until you clear them.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="background-tasks-filters">
|
||||||
|
{{#if hasActiveFilters}}
|
||||||
|
<div class="alert alert-light border d-flex flex-wrap align-items-center justify-content-between gap-2 py-2 mb-3">
|
||||||
|
<div class="small text-muted">
|
||||||
|
{{#if activeSourceLabel}}Showing results from {{activeSourceLabel}}.{{/if}}
|
||||||
|
</div>
|
||||||
|
<a class="btn btn-outline-secondary btn-sm" href="{{clearFiltersUrl}}" data-background-tasks-nav="clear">Clear filters</a>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="background-tasks-summary" class="row g-3 mb-4">
|
||||||
|
{{#each statusCards}}
|
||||||
|
<div class="col-6 col-xl-3">
|
||||||
|
<div class="card card-outline card-{{variant}} h-100{{#if active}} border-2 border-{{variant}}{{/if}}">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="text-muted small text-uppercase">{{label}}</div>
|
||||||
|
<div class="fs-3 fw-semibold">{{count}}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/each}}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<h3 class="card-title mb-0">Task Queue</h3>
|
||||||
|
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
|
||||||
|
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
|
||||||
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
|
||||||
|
</div>
|
||||||
|
{{#if canManage}}
|
||||||
|
{{#if canClearFinished}}
|
||||||
|
<form method="post" action="/settings/background-tasks/clear-finished">
|
||||||
|
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
|
||||||
|
</form>
|
||||||
|
{{/if}}
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body table-responsive p-0">
|
||||||
|
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Task</th>
|
||||||
|
<th>Category</th>
|
||||||
|
<th>Status</th>
|
||||||
|
<th>Queued</th>
|
||||||
|
<th>Started</th>
|
||||||
|
<th>Finished</th>
|
||||||
|
<th>Source</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#if tasks.length}}
|
||||||
|
{{#each tasks}}
|
||||||
|
<tr data-table-search-row>
|
||||||
|
<td data-label="Task">
|
||||||
|
<div class="fw-semibold">{{title}}</div>
|
||||||
|
{{#if key}}
|
||||||
|
<div class="text-muted small text-break">{{key}}</div>
|
||||||
|
{{/if}}
|
||||||
|
{{#if errorMessage}}
|
||||||
|
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Category">{{category}}</td>
|
||||||
|
<td data-label="Status">
|
||||||
|
{{#if (eq status 'queued')}}
|
||||||
|
<span class="badge text-bg-secondary">Queued</span>
|
||||||
|
{{else if (eq status 'running')}}
|
||||||
|
<span class="badge text-bg-primary">Running</span>
|
||||||
|
{{else if (eq status 'completed')}}
|
||||||
|
<span class="badge text-bg-success">Completed</span>
|
||||||
|
{{else if (eq status 'failed')}}
|
||||||
|
<span class="badge text-bg-danger">Failed</span>
|
||||||
|
{{else if (eq status 'canceled')}}
|
||||||
|
<span class="badge text-bg-warning">Canceled</span>
|
||||||
|
{{else}}
|
||||||
|
<span class="badge text-bg-light text-dark">{{status}}</span>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Queued">
|
||||||
|
{{#if createdAt}}
|
||||||
|
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
|
||||||
|
{{else}}
|
||||||
|
-
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Started">
|
||||||
|
{{#if startedAt}}
|
||||||
|
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
|
||||||
|
{{else}}
|
||||||
|
-
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Finished">
|
||||||
|
{{#if finishedAt}}
|
||||||
|
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
|
||||||
|
{{else}}
|
||||||
|
-
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Source">
|
||||||
|
{{#if metadata.sourceName}}
|
||||||
|
<div>{{metadata.sourceName}}</div>
|
||||||
|
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||||
|
{{else}}
|
||||||
|
<span class="empty">-</span>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
{{else}}
|
||||||
|
<tr data-table-search-empty-default>
|
||||||
|
<td colspan="7" class="empty">No background tasks are queued right now.</td>
|
||||||
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
{{#if pagination.hasMultiplePages}}
|
||||||
|
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||||
|
<div class="text-muted small">
|
||||||
|
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
|
||||||
|
</div>
|
||||||
|
<nav aria-label="Background task pages">
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
||||||
|
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||||
|
</li>
|
||||||
|
{{#each pagination.pages}}
|
||||||
|
<li class="page-item {{#if active}}active{{/if}}">
|
||||||
|
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
||||||
|
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
<div id="background-tasks-state" data-state-version="{{stateVersion}}" hidden></div>
|
||||||
|
|
||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h2>Scheduled tasks</h2>
|
||||||
|
<p>Use this page to view and manage recurring tasks.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
|
||||||
|
<div class="card-header d-flex align-items-center">
|
||||||
|
<h3 class="card-title mb-0">Scheduled Tasks</h3>
|
||||||
|
<div class="ms-auto d-flex flex-nowrap align-items-center background-tasks-recurring-tools">
|
||||||
|
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
|
||||||
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-body table-responsive p-0">
|
||||||
|
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Task</th>
|
||||||
|
<th>Interval</th>
|
||||||
|
<th>Next run</th>
|
||||||
|
<th>Last run</th>
|
||||||
|
<th>Last result</th>
|
||||||
|
<th>Source</th>
|
||||||
|
{{#if canManage}}<th>Actions</th>{{/if}}
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{{#if recurringTasks.length}}
|
||||||
|
{{#each recurringTasks}}
|
||||||
|
<tr data-table-search-row>
|
||||||
|
<td data-label="Task">
|
||||||
|
<div class="fw-semibold">{{title}}</div>
|
||||||
|
<div class="text-muted small text-break">{{key}}</div>
|
||||||
|
</td>
|
||||||
|
<td data-label="Interval">{{intervalLabel}}</td>
|
||||||
|
<td data-label="Next run">
|
||||||
|
{{#if nextRunAt}}
|
||||||
|
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
|
||||||
|
{{else}}-{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Last run">
|
||||||
|
{{#if lastRunAt}}
|
||||||
|
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
|
||||||
|
{{else}}-{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Last result">
|
||||||
|
{{#if lastStatus}}
|
||||||
|
<span class="badge text-capitalize {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
|
||||||
|
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
|
||||||
|
{{else}}
|
||||||
|
<span class="empty">-</span>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
<td data-label="Source">
|
||||||
|
{{#if metadata.sourceName}}
|
||||||
|
<div>{{metadata.sourceName}}</div>
|
||||||
|
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
|
||||||
|
{{else}}
|
||||||
|
<span class="empty">-</span>
|
||||||
|
{{/if}}
|
||||||
|
</td>
|
||||||
|
{{#if ../canManage}}
|
||||||
|
<td data-label="Actions">
|
||||||
|
<div class="d-flex flex-wrap gap-2">
|
||||||
|
<form method="post" action="/settings/background-tasks/recurring/{{key}}/run">
|
||||||
|
<button type="submit" class="btn btn-outline-primary btn-sm">Run now</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
{{/if}}
|
||||||
|
</tr>
|
||||||
|
{{/each}}
|
||||||
|
{{else}}
|
||||||
|
<tr data-table-search-empty-default>
|
||||||
|
<td colspan="{{#if canManage}}7{{else}}6{{/if}}" class="empty">No scheduled refreshes are registered yet.</td>
|
||||||
|
</tr>
|
||||||
|
{{/if}}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -5,70 +5,70 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row g-4">
|
<form id="user-form" method="post" action="/users">
|
||||||
<div class="col-12 col-xl-4">
|
<div class="row g-4">
|
||||||
<div class="card card-outline card-primary admin-form-card">
|
<div class="col-12 col-xl-4">
|
||||||
<div class="card-header">
|
<div class="card card-outline card-primary admin-form-card">
|
||||||
<h3 class="card-title">User details</h3>
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">User details</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="row g-3">
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label for="user-username" class="form-label">Username</label>
|
||||||
|
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label for="user-name" class="form-label">Name</label>
|
||||||
|
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label for="user-password" class="form-label">Password</label>
|
||||||
|
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||||
|
</div>
|
||||||
|
<div class="col-12 col-md-6">
|
||||||
|
<label for="user-confirm-password" class="form-label">Confirm password</label>
|
||||||
|
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card-footer d-flex justify-content-end">
|
||||||
|
<div class="btn-group" role="group" aria-label="User actions">
|
||||||
|
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
|
||||||
|
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<form id="user-form" method="post" action="/users">
|
</div>
|
||||||
|
|
||||||
|
<div class="col-12 col-xl-8">
|
||||||
|
<div class="card card-outline card-secondary admin-form-card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3 class="card-title">Roles</h3>
|
||||||
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row g-3">
|
<div class="row g-3">
|
||||||
<div class="col-12 col-md-6">
|
{{#if roles.length}}
|
||||||
<label for="user-username" class="form-label">Username</label>
|
{{#each roles}}
|
||||||
<input id="user-username" type="text" name="username" class="form-control" autocomplete="username" value="{{formValues.username}}" required />
|
<div class="col-4">
|
||||||
</div>
|
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
||||||
<div class="col-12 col-md-6">
|
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
||||||
<label for="user-name" class="form-label">Name</label>
|
<span class="form-check-label pe-4">
|
||||||
<input id="user-name" type="text" name="name" class="form-control" autocomplete="name" value="{{formValues.name}}" required />
|
<strong>{{name}}</strong>
|
||||||
</div>
|
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
||||||
<div class="col-12 col-md-6">
|
</span>
|
||||||
<label for="user-password" class="form-label">Password</label>
|
</label>
|
||||||
<input id="user-password" type="password" name="password" class="form-control" autocomplete="new-password" minlength="8" required />
|
</div>
|
||||||
</div>
|
{{/each}}
|
||||||
<div class="col-12 col-md-6">
|
{{else}}
|
||||||
<label for="user-confirm-password" class="form-label">Confirm password</label>
|
<div class="col-12">
|
||||||
<input id="user-confirm-password" type="password" name="confirm_password" class="form-control" autocomplete="new-password" minlength="8" required />
|
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="card-footer d-flex justify-content-end">
|
|
||||||
<div class="btn-group" role="group" aria-label="User actions">
|
|
||||||
{{{saveActionButtons formId="user-form" saveLabel="Save" showSaveAndClose=false showSaveAndNew=false}}}
|
|
||||||
<a class="btn btn-warning" href="/users" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="col-12 col-xl-8">
|
|
||||||
<div class="card card-outline card-secondary admin-form-card">
|
|
||||||
<div class="card-header">
|
|
||||||
<h3 class="card-title">Roles</h3>
|
|
||||||
</div>
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row g-3">
|
|
||||||
{{#if roles.length}}
|
|
||||||
{{#each roles}}
|
|
||||||
<div class="col-4">
|
|
||||||
<label class="form-check card card-outline card-secondary position-relative p-3 h-100 mb-0">
|
|
||||||
<input class="form-check-input position-absolute top-0 end-0 m-3" type="checkbox" name="role_ids[]" value="{{id}}" {{#if isSelected}}checked{{/if}} />
|
|
||||||
<span class="form-check-label pe-4">
|
|
||||||
<strong>{{name}}</strong>
|
|
||||||
<span class="d-block text-body-secondary small">{{#if description}}{{description}}{{else}}No description provided.{{/if}}</span>
|
|
||||||
</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
</div>
|
||||||
{{/each}}
|
{{/if}}
|
||||||
{{else}}
|
</div>
|
||||||
<div class="col-12">
|
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
|
||||||
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</form>
|
||||||
|
|||||||
@@ -5,17 +5,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Existing users</h3>
|
<h3 class="card-title">Existing users</h3>
|
||||||
{{#if (hasPermission currentUser 'users.create')}}
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
<div class="card-tools">
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
<a class="btn btn-primary btn-sm" href="/users/new">Add user</a>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search users" aria-label="Search users" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{#if (hasPermission currentUser 'users.create')}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="/users/new">Add user</a>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table class="table table-striped w-100 mb-0 users-table">
|
<table class="table table-striped w-100 mb-0 users-table" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>User</th>
|
<th>User</th>
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#if users.length}}
|
{{#if users.length}}
|
||||||
{{#each users}}
|
{{#each users}}
|
||||||
<tr>
|
<tr data-table-search-row>
|
||||||
<td data-label="User">
|
<td data-label="User">
|
||||||
<div class="user-cell">
|
<div class="user-cell">
|
||||||
<div>
|
<div>
|
||||||
@@ -85,9 +89,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="4" class="empty">No users found.</td></tr>
|
<tr data-table-search-empty-default><td colspan="4" class="empty">No users found.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/users"}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -213,7 +213,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if (anyPermission currentUser 'background-tasks.read' 'users.read' 'rbac.read')}}
|
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read')}}
|
||||||
<li class="nav-header">SETTINGS</li>
|
<li class="nav-header">SETTINGS</li>
|
||||||
{{#if (hasPermission currentUser 'users.read')}}
|
{{#if (hasPermission currentUser 'users.read')}}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
@@ -231,12 +231,33 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
|
||||||
<li class="nav-item">
|
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
|
||||||
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
|
||||||
<i class="nav-icon bi bi-list-task"></i>
|
<i class="nav-icon bi bi-pen"></i>
|
||||||
<p>Background tasks</p>
|
<p>
|
||||||
|
Tasks
|
||||||
|
<i class="nav-arrow bi bi-chevron-right"></i>
|
||||||
|
</p>
|
||||||
</a>
|
</a>
|
||||||
|
{{#if (anyPermission currentUser 'background-tasks.read')}}
|
||||||
|
<ul class="nav nav-treeview">
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
|
||||||
|
<i class="nav-icon bi bi-list-ol"></i>
|
||||||
|
<p>Task queue</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
{{#if (hasPermission currentUser 'scheduled-tasks.read')}}
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link {{#if (eq active 'background-tasks-scheduled')}}active{{/if}}" href="/settings/background-tasks/scheduled">
|
||||||
|
<i class="nav-icon bi bi-calendar-event"></i>
|
||||||
|
<p>Scheduled tasks</p>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{{/if}}
|
||||||
|
</ul>
|
||||||
</li>
|
</li>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="modal fade" id="{{modalId}}" tabindex="-1" aria-labelledby="{{modalLabelId}}" aria-hidden="true"{{#if modalBackdropStatic}} data-bs-backdrop="static"{{/if}}{{#if modalKeyboardDisabled}} data-bs-keyboard="false"{{/if}}>
|
||||||
|
<div class="modal-dialog{{#if modalDialogClass}} {{modalDialogClass}}{{/if}}">
|
||||||
|
<div class="modal-content">
|
||||||
|
{{> @partial-block }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
{{#if pagination.hasMultiplePages}}
|
||||||
|
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
|
||||||
|
<div class="text-muted small">
|
||||||
|
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} {{pagination.itemLabel}}
|
||||||
|
</div>
|
||||||
|
<nav aria-label="{{pagination.ariaLabel}}">
|
||||||
|
<ul class="pagination pagination-sm mb-0">
|
||||||
|
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
|
||||||
|
<a class="page-link" href="{{#if pagination.hasPrevious}}{{basePath}}{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
|
||||||
|
</li>
|
||||||
|
{{#each pagination.pages}}
|
||||||
|
<li class="page-item {{#if active}}active{{/if}}">
|
||||||
|
<a class="page-link" href="{{../basePath}}{{url}}">{{number}}</a>
|
||||||
|
</li>
|
||||||
|
{{/each}}
|
||||||
|
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
|
||||||
|
<a class="page-link" href="{{#if pagination.hasNext}}{{basePath}}{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
@@ -5,17 +5,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Saved sizes</h3>
|
<h3 class="card-title">Saved sizes</h3>
|
||||||
{{#if (hasPermission currentUser 'canvas-sizes.create')}}
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
<div class="card-tools">
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
<a class="btn btn-primary btn-sm" href="/canvas-sizes/new">Add canvas size</a>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search canvas sizes" aria-label="Search canvas sizes" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{#if (hasPermission currentUser 'canvas-sizes.create')}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="/canvas-sizes/new">Add canvas size</a>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table class="table table-striped w-100 mb-0">
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -27,7 +31,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#if canvasSizes.length}}
|
{{#if canvasSizes.length}}
|
||||||
{{#each canvasSizes}}
|
{{#each canvasSizes}}
|
||||||
<tr>
|
<tr data-table-search-row>
|
||||||
<td data-label="Name">{{name}}</td>
|
<td data-label="Name">{{name}}</td>
|
||||||
<td data-label="Dimensions">{{width}}x{{height}}</td>
|
<td data-label="Dimensions">{{width}}x{{height}}</td>
|
||||||
<td data-label="Templates">{{templateCount}}</td>
|
<td data-label="Templates">{{templateCount}}</td>
|
||||||
@@ -50,9 +54,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="4" class="empty">No canvas sizes yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="4" class="empty">No canvas sizes yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/canvas-sizes"}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,12 +5,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Active connections</h3>
|
<h3 class="card-title">Active connections</h3>
|
||||||
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search clients" aria-label="Search clients" data-table-search />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}">
|
<table id="dashboard-clients-table" class="table table-striped w-100 mb-0" data-has-actions-column="{{#if (hasPermission currentUser 'clients.allow')}}true{{else}}false{{/if}}" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Client</th>
|
<th>Client</th>
|
||||||
@@ -25,7 +31,7 @@
|
|||||||
<tbody id="dashboard-clients-table-body">
|
<tbody id="dashboard-clients-table-body">
|
||||||
{{#if clients.length}}
|
{{#if clients.length}}
|
||||||
{{#each clients}}
|
{{#each clients}}
|
||||||
<tr data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
|
<tr data-table-search-row data-client-key="{{id}}" data-client-id="{{clientId}}" data-client-device-id="{{deviceId}}" data-client-screen-slug="{{screen_slug}}">
|
||||||
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
|
<td data-label="Client" class="client-rename-cell" title="Double-click to rename">
|
||||||
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
|
<div>{{#if client_name}}{{client_name}}{{else}}Unknown{{/if}}</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -95,9 +101,10 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="{{#if (hasPermission currentUser 'clients.allow')}}7{{else}}6{{/if}}" class="empty">No connected clients yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="{{#if (hasPermission currentUser 'clients.allow')}}7{{else}}6{{/if}}" class="empty">No connected clients yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/clients"}}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -12,51 +12,36 @@
|
|||||||
<h3 class="card-title">Playlist details</h3>
|
<h3 class="card-title">Playlist details</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<form id="playlist-edit-form" method="post" action="/playlists/{{playlist.id}}" data-async-save data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new">
|
<form id="playlist-edit-form" method="post" action="/playlists/{{playlist.id}}" data-async-save data-async-save-reload-on-success data-async-save-close-url="/playlists" data-async-save-new-url="/playlists/new">
|
||||||
<div class="row g-3 align-items-end">
|
<div class="row g-2 align-items-end">
|
||||||
<div class="col-12 col-lg-8">
|
<div class="col-12 col-lg-8">
|
||||||
<label for="playlist-name" class="form-label">Name</label>
|
<label for="playlist-name" class="form-label">Name</label>
|
||||||
<input id="playlist-name" name="name" class="form-control" value="{{playlist.name}}" required />
|
<input id="playlist-name" name="name" class="form-control" value="{{playlist.name}}" required />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="col-12 col-lg-4 d-flex align-items-lg-end justify-content-lg-start">
|
||||||
|
<div class="d-flex flex-column align-items-lg-start gap-1 w-100">
|
||||||
|
<label class="form-label d-none d-lg-block invisible">Add slide</label>
|
||||||
|
{{#if hasSlides}}
|
||||||
|
{{#if hasSelectableSlides}}
|
||||||
|
<button type="button" class="btn btn-primary w-100" id="playlist-open-slide-modal" data-bs-toggle="modal" data-bs-target="#playlist-add-slide-modal">Add slide</button>
|
||||||
|
{{else}}
|
||||||
|
<div class="text-body-secondary small text-lg-start">
|
||||||
|
{{#if playlistCanvasMismatch}}
|
||||||
|
Remove mismatched slides before adding more.
|
||||||
|
{{else if playlistCanvasSignature}}
|
||||||
|
No slides match this playlist canvas size.
|
||||||
|
{{else}}
|
||||||
|
No slides are currently available for this playlist.
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
{{/if}}
|
||||||
|
{{else}}
|
||||||
|
<div class="text-body-secondary small text-lg-start">Create slides first.</div>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div class="row g-3 mt-1 align-items-start">
|
|
||||||
<div class="col-12">
|
|
||||||
<h4 class="h6 mb-2 text-uppercase text-muted">Add slide to playlist</h4>
|
|
||||||
{{#if hasSlides}}
|
|
||||||
{{#if hasSelectableSlides}}
|
|
||||||
<form id="playlist-add-slide-form">
|
|
||||||
<div class="row g-2 align-items-end playlist-add-slide-form">
|
|
||||||
<div class="col-12 col-md-8">
|
|
||||||
<select id="playlist-add-slide-select" class="form-select" required>
|
|
||||||
{{#each selectableSlides}}
|
|
||||||
<option value="{{id}}" data-canvas-signature="{{canvasSignature}}" {{#if isAssigned}}disabled{{/if}}>{{title}}</option>
|
|
||||||
{{/each}}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="col-12 col-md-4 d-flex justify-content-md-end">
|
|
||||||
<button type="button" class="btn btn-primary w-100 w-md-auto" id="playlist-add-slide-button">Add slide</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
<div id="playlist-add-slide-empty" class="empty mt-2 playlist-add-slide-empty is-hidden">No slides are currently available for this playlist.</div>
|
|
||||||
{{else}}
|
|
||||||
<div class="empty mb-0">
|
|
||||||
{{#if playlistCanvasMismatch}}
|
|
||||||
This playlist contains slides with different canvas sizes. Remove the mismatched items before adding more.
|
|
||||||
{{else if playlistCanvasSignature}}
|
|
||||||
No slides match this playlist canvas size.
|
|
||||||
{{else}}
|
|
||||||
No slides are currently available for this playlist.
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
{{/if}}
|
|
||||||
{{else}}
|
|
||||||
<div class="empty mb-0">Create slides first.</div>
|
|
||||||
{{/if}}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-end">
|
<div class="card-footer d-flex justify-content-end">
|
||||||
<div class="btn-group playlist-actions-group" role="group" aria-label="Playlist actions">
|
<div class="btn-group playlist-actions-group" role="group" aria-label="Playlist actions">
|
||||||
@@ -77,6 +62,10 @@
|
|||||||
<input class="form-check-input" type="checkbox" role="switch" id="playlist-fade-between-slides" name="fade_between_slides" value="1" form="playlist-edit-form" {{#if playlistFadeBetweenSlides}}checked{{/if}} />
|
<input class="form-check-input" type="checkbox" role="switch" id="playlist-fade-between-slides" name="fade_between_slides" value="1" form="playlist-edit-form" {{#if playlistFadeBetweenSlides}}checked{{/if}} />
|
||||||
<label class="form-check-label" for="playlist-fade-between-slides">Fade between slides</label>
|
<label class="form-check-label" for="playlist-fade-between-slides">Fade between slides</label>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="form-check form-switch mt-3 mb-0">
|
||||||
|
<input class="form-check-input" type="checkbox" role="switch" id="playlist-skip-unavailable-rtmp" name="skip_unavailable_rtmp" value="1" form="playlist-edit-form" {{#if playlistSkipUnavailableRtmp}}checked{{/if}} />
|
||||||
|
<label class="form-check-label" for="playlist-skip-unavailable-rtmp">Skip RTMP slides when stream is unavailable</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -110,7 +99,18 @@
|
|||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Slide">
|
<td data-label="Slide">
|
||||||
{{title}}
|
<div class="playlist-slide-cell">
|
||||||
|
<div class="playlist-slide-thumb" aria-hidden="true" {{#if canvas_width}}style="--playlist-slide-thumb-aspect-ratio: {{canvas_width}} / {{canvas_height}};"{{/if}}>
|
||||||
|
{{#if thumbnail_path}}
|
||||||
|
<img class="playlist-slide-thumb-image" src="{{thumbnail_path}}" alt="" loading="lazy" />
|
||||||
|
{{else}}
|
||||||
|
<span class="playlist-slide-thumb-placeholder"><i class="bi bi-image" aria-hidden="true"></i></span>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
|
<div class="playlist-slide-cell-content">
|
||||||
|
<span class="playlist-slide-title">{{title}}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<input type="hidden" name="slide_id[]" value="{{slide_id}}" form="playlist-edit-form" />
|
<input type="hidden" name="slide_id[]" value="{{slide_id}}" form="playlist-edit-form" />
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Schedule">
|
<td data-label="Schedule">
|
||||||
@@ -123,7 +123,7 @@
|
|||||||
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
|
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Duration">
|
<td data-label="Duration">
|
||||||
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" />
|
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" />
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="actions playlist-item-actions">
|
<div class="actions playlist-item-actions">
|
||||||
@@ -143,5 +143,39 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script type="application/json" id="playlist-add-slide-data">{{json selectableSlides}}</script>
|
||||||
|
|
||||||
|
{{#> modal-shell modalId="playlist-add-slide-modal" modalLabelId="playlist-add-slide-modal-label" modalDialogClass="modal-dialog-centered modal-dialog-scrollable modal-xl" modalBackdropStatic=true modalKeyboardDisabled=true}}
|
||||||
|
<div class="modal-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="modal-title fs-5" id="playlist-add-slide-modal-label">Add slides to playlist</h2>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body playlist-slide-picker-modal-body">
|
||||||
|
<div class="playlist-slide-picker-toolbar d-flex flex-column flex-lg-row gap-3 align-items-stretch align-items-lg-end">
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<label class="form-label mb-1" for="playlist-slide-picker-search">Search</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" id="playlist-slide-picker-search" placeholder="Search slide titles" autocomplete="off" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-slide-picker-filter mb-0">
|
||||||
|
<button type="button" class="btn btn-outline-secondary playlist-slide-picker-filter-button" id="playlist-slide-picker-show-assigned" aria-pressed="false">Show already added slides</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="playlist-slide-picker-empty empty is-hidden" id="playlist-slide-picker-empty">No slides match your search.</div>
|
||||||
|
<div class="playlist-slide-picker-grid" id="playlist-slide-picker-grid" aria-live="polite"></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer justify-content-end">
|
||||||
|
<div class="d-flex flex-wrap align-items-center justify-content-end gap-2 w-100">
|
||||||
|
<div class="text-body-secondary small text-end" id="playlist-slide-picker-selected-count-wrap"><span id="playlist-slide-picker-selected-count">0</span> selected</div>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" id="playlist-confirm-add-slides" disabled>Add selected slides</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{{/modal-shell}}
|
||||||
|
|
||||||
<form id="delete-playlist-form" method="post" action="/playlists/{{playlist.id}}/delete" data-confirm-message="Delete this playlist?"></form>
|
<form id="delete-playlist-form" method="post" action="/playlists/{{playlist.id}}/delete" data-confirm-message="Delete this playlist?"></form>
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Existing playlists</h3>
|
<h3 class="card-title">Existing playlists</h3>
|
||||||
{{#if (hasPermission currentUser 'playlists.create')}}
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
<div class="card-tools">
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
<a class="btn btn-primary btn-sm" href="/playlists/new">Create playlist</a>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search playlists" aria-label="Search playlists" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{#if (hasPermission currentUser 'playlists.create')}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="/playlists/new">Create playlist</a>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table class="table table-striped w-100 mb-0">
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -26,7 +30,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#if playlists.length}}
|
{{#if playlists.length}}
|
||||||
{{#each playlists}}
|
{{#each playlists}}
|
||||||
<tr>
|
<tr data-table-search-row>
|
||||||
<td data-label="Name">{{name}}</td>
|
<td data-label="Name">{{name}}</td>
|
||||||
<td data-label="Slides"><span class="chip">{{slideCount}} slide{{#unless slideCountIsOne}}s{{/unless}}</span></td>
|
<td data-label="Slides"><span class="chip">{{slideCount}} slide{{#unless slideCountIsOne}}s{{/unless}}</span></td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
@@ -48,10 +52,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="3" class="empty">No playlists yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="3" class="empty">No playlists yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/playlists"}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,17 +5,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card card-outline card-primary">
|
<div class="card card-outline card-primary" data-table-search-container>
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<h3 class="card-title">Existing screens</h3>
|
<h3 class="card-title">Existing screens</h3>
|
||||||
{{#if (hasPermission currentUser 'screens.create')}}
|
<div class="card-tools d-flex flex-nowrap align-items-center gap-2">
|
||||||
<div class="card-tools">
|
<div class="input-group input-group-sm flex-shrink-1" style="width: min(14rem, 100%);">
|
||||||
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen</a>
|
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
|
||||||
|
<input type="search" class="form-control" placeholder="Search screens" aria-label="Search screens" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
{{/if}}
|
{{#if (hasPermission currentUser 'screens.create')}}
|
||||||
|
<a class="btn btn-primary btn-sm" href="/screens/new">Add screen</a>
|
||||||
|
{{/if}}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-body table-responsive p-0">
|
<div class="card-body table-responsive p-0">
|
||||||
<table class="table table-striped w-100 mb-0">
|
<table class="table table-striped w-100 mb-0" data-table-searchable>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
@@ -28,7 +32,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{{#if screens.length}}
|
{{#if screens.length}}
|
||||||
{{#each screens}}
|
{{#each screens}}
|
||||||
<tr>
|
<tr data-table-search-row>
|
||||||
<td data-label="Name">{{name}}</td>
|
<td data-label="Name">{{name}}</td>
|
||||||
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
|
<td data-label="Player URL"><a href="{{playerUrl slug}}" target="_blank">{{playerUrl slug}}</a></td>
|
||||||
<td data-label="Playlist">{{playlist_name}}</td>
|
<td data-label="Playlist">{{playlist_name}}</td>
|
||||||
@@ -58,10 +62,11 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr><td colspan="5" class="empty">No screens yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="5" class="empty">No screens yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
{{> table-pagination pagination=pagination basePath="/screens"}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user