Compare commits

...
11 Commits
Author SHA1 Message Date
lzstealth 2176fb9042 Release v1.5.11 2026-07-26 15:01:03 +01:00
lzstealth eb1f33d82f Implement video region support 2026-07-26 14:54:50 +01:00
lzstealth 07ec17ad91 Release v1.5.10 2026-07-26 12:32:01 +01:00
lzstealth b5fbf79af7 Gitea Templates Update 2026-07-26 01:45:16 +01:00
lzstealth 4ca4da473f Gitea Templates 2026-07-26 01:35:57 +01:00
lzstealth f4de31269d Commit all changes 2026-07-26 01:28:38 +01:00
lzstealth 235d2da6aa Release 1.5.9 2026-07-26 01:28:21 +01:00
lzstealth d24c9c4035 Release 1.5.8 2026-07-25 18:24:12 +01:00
lzstealth 04ddec50ad Release 1.5.7 2026-07-25 17:58:00 +01:00
lzstealth 47613e61a2 Tune playlist slide thumbnail placeholder 2026-07-25 17:49:00 +01:00
lzstealth bd130a1070 Bump version to 1.5.6 2026-07-25 17:43:41 +01:00
55 changed files with 2844 additions and 879 deletions
+48
View File
@@ -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:
+5
View File
@@ -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.
+37
View File
@@ -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.
+62
View File
@@ -6,6 +6,68 @@ All notable changes to this project will be documented in this file.
- No unreleased changes recorded yet.
## 1.5.11 - 2026-07-26
### Added
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
### Changed
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
- Player rendering and related media handling now recognize the new video region implementation.
### Fixed
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
## 1.5.10 - 2026-07-26
### Added
- Scheduled background tasks now have their own settings page, separate permission gate, and manual run action alongside the main queue view.
- The slide editor now includes a refreshed preview/sidebar layout and a cropper-based image upload flow.
### Changed
- Background task and admin list views now use richer pagination and filtering so queue state, task sources, and recurring jobs stay easier to navigate.
- The admin shell and shared styling were refreshed to support the updated settings pages, slide editor layout, and task table presentation.
### Fixed
- Upload sync and slide thumbnail refresh handling were tightened so slide changes queue follow-up work more reliably.
## 1.5.9 - 2026-07-26
### Fixed
- User creation now keeps the role checkboxes inside the add-user form so selected roles are submitted correctly.
## 1.5.8 - 2026-07-25
### Fixed
- 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
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "pulse-signage",
"version": "1.5.4",
"version": "1.5.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "pulse-signage",
"version": "1.5.4",
"version": "1.5.11",
"dependencies": {
"@sparticuz/chromium": "^137.0.0",
"bootstrap-icons": "1.11.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "pulse-signage",
"version": "1.5.5",
"version": "1.5.11",
"private": false,
"description": "Pulse Signage application with MySQL and media storage",
"repository": {
+1 -1
View File
@@ -25,7 +25,7 @@ async function fetchAdminData(pool) {
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, sl.content_json, sl.thumbnail_path, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
+12
View File
@@ -92,6 +92,18 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
type: 'image',
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
};
} else if (region.region_type === 'video') {
const uploaded = filesByField[`region_video_${region.id}`];
const existing = body[`existing_region_video_${region.id}`];
const durationValue = body[`existing_region_video_duration_${region.id}`];
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
const parsedDuration = Number(durationValue || existingDuration || 0);
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
content[region.region_key] = {
type: 'video',
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
};
} else if (region.region_type === 'webpage') {
const submitted = body[`region_webpage_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
+1 -1
View File
@@ -1,6 +1,6 @@
const { parseJsonSafe, readFormArray } = require('./utils');
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
function sanitizeBackgroundColor(value) {
+2 -1
View File
@@ -117,7 +117,8 @@ async function ensureSchema(pool, options) {
playlist_id INT NOT NULL,
slide_id INT NOT NULL,
position INT NOT NULL DEFAULT 0,
duration_seconds INT NOT NULL DEFAULT 10,
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
schedule_start_datetime DATETIME NULL,
schedule_end_datetime DATETIME NULL,
+31 -13
View File
@@ -605,20 +605,20 @@ const migrations = [
{
key: 'schema-columns-current',
version: appVersion,
comment: 'Add the current table columns and audit fields that define the released schema.',
comment: 'Backfill the released schema columns for older databases.',
order: 20,
up: async function (pool) {
// v1.4.6: keep the current canvas_sizes shape available in older databases.
// Current released schema: keep canvas_sizes audit fields available in older databases.
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'canvas_sizes');
// v1.4.6: playlists gained fade_between_slides plus audit fields.
// Current released schema: ensure playlists carry the playback and audit fields.
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlists');
// v1.4.6: slide_templates now store canvas sizing and background metadata.
// Current released schema: keep slide template canvas and background fields available.
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
@@ -626,13 +626,13 @@ const migrations = [
await addAuditColumns(pool, 'slide_templates');
await backfillLegacySlideTemplateCanvasSize(pool);
// v1.4.6: slide_template_regions gained font family and audit fields.
// Current released schema: keep slide template region typography and audit fields available.
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slide_template_regions');
// v1.4.6: slides gained structured content and media fields.
// Current released schema: keep structured slide content and media fields available.
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
@@ -642,8 +642,8 @@ const migrations = [
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'slides');
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
@@ -653,12 +653,12 @@ const migrations = [
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'playlist_slides');
// v1.4.6: screens gained an optional playlist binding and audit fields.
// Current released schema: keep screen playlist bindings and audit fields available.
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'screens');
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
// Current released schema: keep RSS feed interval and audit fields available.
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
@@ -667,14 +667,14 @@ const migrations = [
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'rss_feeds');
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
// Current released schema: keep normalized RSS feed item snapshots available.
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await backfillLegacyRssFeedItemJson(pool);
// v1.4.6: API sources now track their latest response snapshot.
// Current released schema: keep API source polling and snapshot fields available.
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
@@ -687,7 +687,7 @@ const migrations = [
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
await addAuditColumns(pool, 'api_sources');
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
// Current released schema: keep onboarding device and RBAC audit fields available.
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
@@ -741,6 +741,24 @@ const migrations = [
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
}
},
{
key: 'playlist-slide-use-video-duration-flag',
version: appVersion,
comment: 'Add the playlist slide flag that follows the current video duration.',
order: 23,
up: async function (pool) {
await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
}
},
{
key: 'playlist-slide-duration-decimals',
version: appVersion,
comment: 'Widen playlist slide durations to preserve fractional seconds.',
order: 24,
up: async function (pool) {
await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
}
},
{
key: 'media-path-prefix-rename',
version: appVersion,
+1
View File
@@ -13,6 +13,7 @@
let slideMarkupCache = Object.create(null);
let templateLayoutCache = Object.create(null);
let templateRenderPlanCache = Object.create(null);
let videoRegionRenderVersion = 0;
let renderCacheViewportKey = '';
let index = 0;
let timer = null;
+40 -5
View File
@@ -73,11 +73,11 @@ function createPlayerPlaylistService(options) {
return payloadWithoutPlaylist;
}
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const [playlistRows] = await pool.query('SELECT id, name, fade_between_slides, skip_unavailable_rtmp, created_at, modified_at, created_by, modified_by FROM playlists WHERE id = ?', [screen.playlist_id]);
const playlist = playlistRows[0] || null;
const [slideRows] = await pool.query(`
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
@@ -108,12 +108,46 @@ function createPlayerPlaylistService(options) {
});
}
function getVideoRegionDurationSeconds(contentJson) {
if (!contentJson) {
return null;
}
try {
const parsed = common.parseJsonSafe(contentJson) || {};
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
const slides = slideRows.map(function (slide) {
const storedDuration = Number(slide.duration_seconds || 0);
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
const content = common.parseJsonSafe(slide.content_json) || {};
Object.keys(content).forEach(function (key) {
const region = content[key];
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
region.cache_bust = videoCacheBust;
}
});
return {
id: slide.id,
title: slide.title,
body: slide.body,
duration_seconds: slide.duration_seconds,
modified_at: slide.modified_at,
duration_seconds: videoDuration || storedDuration,
use_video_duration: Boolean(slide.use_video_duration),
schedule_mode: slide.schedule_mode,
schedule_start_datetime: slide.schedule_start_datetime,
schedule_end_datetime: slide.schedule_end_datetime,
@@ -125,7 +159,7 @@ function createPlayerPlaylistService(options) {
kind: common.mediaKind(slide.media_path),
template_id: slide.template_id,
template: slide.template_id ? templatesById[slide.template_id] || null : null,
content: common.parseJsonSafe(slide.content_json) || {}
content: content
};
});
@@ -193,6 +227,7 @@ function createPlayerPlaylistService(options) {
updatePlaylistRevisionHash(hash, slide.modified_at);
updatePlaylistRevisionHash(hash, slide.position);
updatePlaylistRevisionHash(hash, slide.duration_seconds);
updatePlaylistRevisionHash(hash, slide.use_video_duration);
updatePlaylistRevisionHash(hash, slide.schedule_mode);
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
+13
View File
@@ -364,6 +364,19 @@ body.screen-blackout #app {
display: block;
}
.template-region.video {
background: transparent;
}
.template-region.video video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
pointer-events: none;
background: transparent;
}
.template-region.webpage iframe {
width: 100%;
height: 100%;
+43 -3
View File
@@ -89,10 +89,10 @@ function scheduleSlideAdvance(delayMs) {
}, holdDelayMs);
}
// Add the fade time to a slide's hold duration so the configured duration remains visible.
function getSlideHoldDelay(delayMs) {
// Add the fade time to a slide's hold duration unless the slide duration already accounts for it.
function getSlideHoldDelay(delayMs, skipFadePadding) {
var holdDelayMs = Math.max(1, Number(delayMs || 0));
return holdDelayMs + (currentPlaylistFadeBetweenSlides ? slideFadeDurationMs : 0);
return holdDelayMs + (currentPlaylistFadeBetweenSlides && !skipFadePadding ? slideFadeDurationMs : 0);
}
// Cancel any pending fade-transition cleanup.
@@ -109,11 +109,48 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof destroyRtmpRegions === 'function') {
destroyRtmpRegions(app);
}
function initializeRenderedVideoPlayback(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('.template-region.video video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
function startPlayback() {
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
if (video.readyState >= 2) {
startPlayback();
return;
}
video.addEventListener('canplay', startPlayback, { once: true });
video.addEventListener('loadedmetadata', startPlayback, { once: true });
});
}
if (!shouldFade) {
app.innerHTML = markup;
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(app);
}
initializeRenderedVideoPlayback(app);
return app.firstElementChild;
}
@@ -143,6 +180,7 @@ function renderSlideMarkup(markup, shouldFade) {
if (typeof syncRtmpRegions === 'function') {
syncRtmpRegions(nextShell);
}
initializeRenderedVideoPlayback(nextShell);
return nextShell;
}
@@ -162,6 +200,8 @@ function renderSlideMarkup(markup, shouldFade) {
syncRtmpRegions(nextShell);
}
initializeRenderedVideoPlayback(nextShell);
slideTransitionTimer = window.setTimeout(function () {
if (previousShell && previousShell.parentNode) {
previousShell.parentNode.removeChild(previousShell);
+1 -1
View File
@@ -147,7 +147,7 @@ function refresh() {
markRefreshHealthy();
setOfflineBannerVisible(false);
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
scheduleSlideAdvance(getSlideHoldDelay(Math.max(1, Number(lastRenderedSlide.duration_seconds || 10)) * 1000));
}
return;
}
+16 -1
View File
@@ -452,6 +452,9 @@ function getTemplateRenderPlan(template) {
if (region.regionType === 'image') {
return renderImageRegion(region, regionContent);
}
if (region.regionType === 'video') {
return renderVideoRegion(region, regionContent);
}
if (region.regionType === 'webpage') {
return renderWebpageRegion(region, regionContent);
}
@@ -517,9 +520,21 @@ function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHt
// Build the cache key for rendered slide markup.
function getSlideMarkupCacheKey(slide) {
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|');
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
}
function notifyVideoRegionSourceReady() {
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
// Look up a previously rendered slide in the cache.
function getCachedSlideMarkup(slide) {
var cacheKey = getSlideMarkupCacheKey(slide);
+6 -2
View File
@@ -1,4 +1,4 @@
const CACHE_VERSION = 'v1';
const CACHE_VERSION = 'v2';
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
@@ -87,6 +87,10 @@ async function staleWhileRevalidate(request, cacheName) {
return new Response('', { status: 504, statusText: 'Offline' });
}
async function networkOnly(request) {
return fetch(request);
}
self.addEventListener('install', function (event) {
self.skipWaiting();
event.waitUntil(Promise.resolve());
@@ -126,7 +130,7 @@ self.addEventListener('fetch', function (event) {
}
if (url.pathname.startsWith('/media/')) {
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE));
event.respondWith(networkOnly(request));
return;
}
+145
View File
@@ -0,0 +1,145 @@
var videoRegionLastGoodSrcCache = Object.create(null);
var videoRegionProbeStateCache = Object.create(null);
var videoRegionProbeTimerCache = Object.create(null);
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
function getVideoRegionCacheKey(region) {
return String(region && (region.regionKey || region.label) || '').trim();
}
function isDirectlyRenderableSource(src) {
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
}
function appendCacheBust(src, cacheBust) {
var key = String(cacheBust || '').trim();
var raw = String(src || '').trim();
if (!raw || !key) {
return raw;
}
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
}
function getVideoSourceAvailability(src) {
return videoRegionProbeStateCache[String(src || '').trim()] || null;
}
function setVideoSourceAvailability(src, available) {
var key = String(src || '').trim();
if (!key) {
return;
}
videoRegionProbeStateCache[key] = {
available: available === null ? null : Boolean(available),
checkedAt: Date.now()
};
}
function logVideoRegionStatus(message, details, level) {
if (typeof logDebug === 'function') {
logDebug(message, details || '', level || 'info');
}
}
function triggerVideoRegionSourceRefresh() {
if (typeof window.notifyVideoRegionSourceReady === 'function') {
window.notifyVideoRegionSourceReady();
return;
}
if (typeof videoRegionRenderVersion === 'number') {
videoRegionRenderVersion += 1;
}
slideMarkupCache = Object.create(null);
if (typeof showCurrent === 'function' && slides && slides.length) {
showCurrent();
}
}
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
var key = String(src || '').trim();
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
if (key) {
setVideoSourceAvailability(key, true);
}
return;
}
if (videoRegionProbeTimerCache[key]) {
return;
}
if (!isRetry) {
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
} else {
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
}
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
delete videoRegionProbeTimerCache[key];
fetch(key, {
method: 'HEAD',
cache: 'no-store',
credentials: 'same-origin'
}).then(function (response) {
if (response && response.ok) {
setVideoSourceAvailability(key, true);
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
videoRegionLastGoodSrcCache[regionKey] = key;
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
triggerVideoRegionSourceRefresh();
}
return;
}
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
}).catch(function () {
setVideoSourceAvailability(key, false);
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
scheduleVideoSourceProbe(regionKey, key, true);
});
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
}
function renderVideoRegion(region, regionContent) {
var requestedSrc = String(regionContent && regionContent.value || '').trim();
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
var regionKey = getVideoRegionCacheKey(region);
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
if (!requestedSrc) {
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
}
if (isDirectlyRenderableSource(requestedSrc)) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
setVideoSourceAvailability(requestedSrc, true);
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
var requestedState = getVideoSourceAvailability(requestedSrc);
var requestedReady = Boolean(requestedState && requestedState.available === true);
if (requestedReady) {
if (regionKey) {
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
if (cachedSrc) {
if (cachedSrc !== requestedSrc) {
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
}
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
}
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
}
+1
View File
@@ -286,6 +286,7 @@ const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'playe
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
const playerRegionScriptPaths = [
path.join(__dirname, 'regions', 'image.js'),
path.join(__dirname, 'regions', 'video.js'),
path.join(__dirname, 'regions', 'webpage.js'),
path.join(__dirname, 'regions', 'html.js'),
path.join(__dirname, 'regions', 'rtmp.js'),
+1 -1
View File
@@ -114,7 +114,7 @@ function registerPlayerRoutes(app, options) {
});
});
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '100mb' }), requireRequestAuth, async function (req, res, next) {
app.put('/api/media/:filename', express.raw({ type: '*/*', limit: '1gb' }), requireRequestAuth, async function (req, res, next) {
try {
const filePath = resolveMediaFilePath(req.params.filename);
if (!filePath) {
+11 -1
View File
@@ -109,10 +109,20 @@ const PERMISSION_SECTIONS = [
name: 'Background tasks',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View background tasks and scheduled refreshes.' },
{ key: 'read', name: 'Read', description: 'View queued background tasks.' },
{ key: 'allow', name: 'Allow', description: 'Manage queued background tasks and clear finished items.' }
]
},
{
key: 'scheduled-tasks',
order: 101,
name: 'Scheduled tasks',
sectionName: 'Settings',
actions: [
{ key: 'read', name: 'Read', description: 'View scheduled refresh tasks.' },
{ key: 'allow', name: 'Allow', description: 'Run scheduled refreshes manually.' }
]
},
{
key: 'users',
order: 80,
+46 -5
View File
@@ -47,6 +47,19 @@ function canonicalize(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) {
return JSON.stringify(canonicalize(value));
}
@@ -56,10 +69,38 @@ function hashPayload(value) {
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) {
const explicitPath = String(req && req.path ? req.path : '').trim();
if (explicitPath) {
return explicitPath;
return normalizeRequestPath(explicitPath);
}
const rawUrl = String(req && req.url ? req.url : '').trim();
@@ -68,9 +109,9 @@ function getRequestPath(req) {
}
try {
return new URL(rawUrl, 'http://localhost').pathname;
return normalizeRequestPath(new URL(rawUrl, 'http://localhost').pathname);
} 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 pathname = String(options && options.pathname || '').trim();
const pathname = normalizeRequestPath(options && options.pathname || '');
const timestamp = String(options && options.timestamp || Date.now()).trim();
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}`);
@@ -199,7 +240,7 @@ function verifyRequestAuth(req) {
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);
}
+30 -1
View File
@@ -25,6 +25,7 @@ const rbacData = require('./web/lib/rbac-data');
const { createPlayerActionService } = require('./web/lib/player-actions');
const { isClientNameAvailable, withClientNameReservation } = require('./data/client-name-check');
const { createSessionService } = require('./web/lib/session');
const { hasAnyPermission } = require('./rbac');
const {
formatDashboardDate,
readArrayField,
@@ -92,12 +93,15 @@ async function start() {
dashboardRefreshIntervalMs: Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000),
formatDashboardDate: formatDashboardDate,
notifyPlayerScreens: notifyPlayerScreens,
backgroundTaskQueue: backgroundTaskQueue
backgroundTaskQueue: backgroundTaskQueue,
hasAnyPermission: hasAnyPermission,
});
const upload = webBootstrap.upload;
const collectUploadReferencesFromSlide = webBootstrap.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = webBootstrap.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = webBootstrap.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = webBootstrap.removeUnusedUploadFiles;
const collectUploadPathsFromDirectory = webBootstrap.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = webBootstrap.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = webBootstrap.syncExistingUploadsToPlayer;
const runMediaSyncTask = webBootstrap.runMediaSyncTask;
@@ -286,12 +290,14 @@ async function start() {
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
getAuditUserId: getAuditUserId,
redirectAfterSave: redirectAfterSave,
notifyPlayerScreens: notifyPlayerScreens,
broadcastDashboardState: broadcastDashboardState,
backgroundTaskQueue: backgroundTaskQueue,
hasAnyPermission: hasAnyPermission,
getSlideDeleteBlockMessage: playerActionService.getSlideDeleteBlockMessage,
getTemplateDeleteBlockMessage: playerActionService.getTemplateDeleteBlockMessage,
getCanvasSizeDeleteBlockMessage: playerActionService.getCanvasSizeDeleteBlockMessage,
@@ -407,6 +413,28 @@ 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);
@@ -463,6 +491,7 @@ async function start() {
}
await syncRecurringRefreshes();
await registerRecurringMaintenanceTasks();
scheduleInitialDataSourceRefreshes().catch(function (error) {
console.warn('Unable to schedule startup data source refreshes:', error);
});
+4
View File
@@ -120,6 +120,8 @@ function createWebBootstrap(options) {
const collectUploadReferencesFromSlide = uploadSyncService.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = uploadSyncService.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = uploadSyncService.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = uploadSyncService.removeUnusedUploadFiles;
const collectUploadPathsFromDirectory = uploadSyncService.collectUploadPathsFromDirectory;
const syncPlaylistUploadsOnChange = uploadSyncService.syncPlaylistUploadsOnChange;
const syncExistingUploadsToPlayer = uploadSyncService.syncExistingUploadsToPlayer;
const runMediaSyncTask = uploadSyncService.runMediaSyncTask;
@@ -210,6 +212,8 @@ function createWebBootstrap(options) {
collectUploadReferencesFromSlide: collectUploadReferencesFromSlide,
collectUploadReferencesFromTemplate: collectUploadReferencesFromTemplate,
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
syncPlaylistUploadsOnChange: syncPlaylistUploadsOnChange,
syncExistingUploadsToPlayer: syncExistingUploadsToPlayer,
runMediaSyncTask: runMediaSyncTask,
+11
View File
@@ -357,6 +357,16 @@ function createBackgroundTaskQueue(options) {
scheduleRecurringRun(job, job.intervalMs);
}
function runRecurringTask(recurringKey) {
const normalizedKey = normalizeText(recurringKey);
if (!normalizedKey || !recurringJobsByKey.has(normalizedKey)) {
return false;
}
triggerRecurringJob(normalizedKey);
return true;
}
function syncRecurringTaskState(task, status, errorMessage) {
const recurringKey = taskIdToRecurringKey.get(task.id) || (task && task.metadata && task.metadata.recurringKey);
if (!recurringKey) {
@@ -737,6 +747,7 @@ function createBackgroundTaskQueue(options) {
setTaskHandler: setTaskHandler,
registerRecurringTask: registerRecurringTask,
removeRecurringTask: removeRecurringTask,
runRecurringTask: runRecurringTask,
listTasks: listTasks,
listRecurringTasks: listRecurringTasks,
getTaskById: getTaskById,
+25 -10
View File
@@ -17,6 +17,29 @@ function buildQueryString(query) {
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);
@@ -24,16 +47,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
const safeCurrentPage = Math.min(normalizePageNumber(currentPage), totalPages);
const startIndex = totalItems <= 0 ? 0 : (safeCurrentPage - 1) * normalizedPageSize;
const endIndex = totalItems <= 0 ? 0 : Math.min(totalItems, startIndex + normalizedPageSize);
const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
pages.push({
number: pageNumber,
active: pageNumber === safeCurrentPage,
url: buildQueryString(nextQuery)
});
}
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
@@ -60,5 +74,6 @@ function buildPagination(totalItems, currentPage, pageParam, queryState, pageSiz
module.exports = {
normalizePageNumber,
buildQueryString,
buildPaginationPages,
buildPagination
};
+45 -121
View File
@@ -98,6 +98,13 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
: '';
}
if (regionType === 'video') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
? '<video src="' + escapeHtml(src) + '" title="' + escapeHtml(region.label || region.region_key || 'video') + '" muted playsinline preload="metadata"></video>'
: '';
}
if (regionType === 'webpage') {
const src = resolveAssetUrl(baseUrl, rawValue);
return src
@@ -120,124 +127,6 @@ function buildRegionInnerHtml(region, regionContent, baseUrl) {
return buildTextRegionMarkup(region, regionContent);
}
function buildSlideHtml(slide, baseUrl) {
const canvasSize = getCanvasSize(slide);
const template = slide && slide.template ? slide.template : null;
const backgroundColor = sanitizeTextColor(template && template.background_color ? template.background_color : '#111111', '#111111');
const backgroundImagePath = template && template.background_image_path ? resolveAssetUrl(baseUrl, template.background_image_path) : '';
if (!template) {
const mediaPath = String(slide && slide.media_path || '').trim();
const mediaUrl = resolveAssetUrl(baseUrl, mediaPath);
const kind = mediaKind(mediaPath || slide.media_type || '');
const mediaMarkup = kind === 'image' && mediaUrl
? '<img src="' + escapeHtml(mediaUrl) + '" alt="' + escapeHtml(slide.title || 'slide') + '" />'
: '';
return [
'<!doctype html>',
'<html>',
' <head>',
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
' <style>',
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
' body { display: flex; align-items: stretch; justify-content: stretch; }',
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
' .thumbnail-stage .template-region.rtmp { background: #000; }',
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
' .thumbnail-stage .template-region-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; }',
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
' </style>',
' </head>',
' <body>',
' <div class="thumbnail-stage">',
mediaMarkup ? '<div class="slide"><div class="slide-canvas slide-media" style="width:' + canvasSize.width + 'px;height:' + canvasSize.height + 'px;">' + mediaMarkup + '</div></div>' : '',
' </div>',
' </body>',
'</html>'
].join('\n');
}
const regions = Array.isArray(template && template.regions) ? template.regions.slice().sort(function (left, right) {
return Number(left.z_index || 0) - Number(right.z_index || 0) || Number(left.id || 0) - Number(right.id || 0);
}) : [];
const regionsHtml = regions.map(function (region) {
const content = getRegionContent(slide, region);
const left = Math.max(0, Number(region.x || 0));
const top = Math.max(0, Number(region.y || 0));
const width = Math.max(1, Number(region.width || 1));
const height = Math.max(1, Number(region.height || 1));
const regionType = String(region.region_type || 'text').toLowerCase();
const pixelWidth = Math.max(1, Math.round(Number(region.width || 0) || 1));
const pixelHeight = Math.max(1, Math.round(Number(region.height || 0) || 1));
const innerHtml = regionType === 'text'
? buildTextRegionMarkup(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content)
: buildRegionInnerHtml(Object.assign({}, region, { baseStyle: 'left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';', pixelWidth: pixelWidth, pixelHeight: pixelHeight }), content, baseUrl);
if (!innerHtml) {
return '';
}
return [
'<div class="template-region ' + escapeHtml(regionType) + '" style="left:' + left + '%;top:' + top + '%;width:' + width + '%;height:' + height + '%;z-index:' + Number(region.z_index || 0) + ';">',
innerHtml,
'</div>'
].join('');
}).join('');
return [
'<!doctype html>',
'<html>',
' <head>',
' <meta charset="utf-8" />',
' <meta name="viewport" content="width=' + canvasSize.width + ', initial-scale=1" />',
' <link rel="stylesheet" href="' + escapeHtml(baseUrl + '/assets/css/player.css') + '" />',
' <style>',
' html, body { margin: 0; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: #111111; }',
' body { display: flex; align-items: stretch; justify-content: stretch; }',
' .thumbnail-stage { position: relative; width: ' + canvasSize.width + 'px; height: ' + canvasSize.height + 'px; overflow: hidden; background: ' + escapeHtml(backgroundColor) + '; }',
' .thumbnail-stage .template-stage { position: relative; width: 100%; height: 100%; }',
' .thumbnail-stage .template-stage .template-background { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: fill; display: block; z-index: 0; }',
' .thumbnail-stage .template-region { position: absolute; overflow: hidden; box-sizing: border-box; }',
' .thumbnail-stage .template-region.image img { width: 100%; height: 100%; object-fit: contain; display: block; }',
' .thumbnail-stage .template-region.webpage iframe { width: 100%; height: 100%; border: 0; display: block; background: #fff; overflow: hidden; }',
' .thumbnail-stage .template-region.html iframe { width: 100%; height: 100%; border: 0; display: block; background: transparent; overflow: hidden; }',
' .thumbnail-stage .template-region.rtmp { background: #000; }',
' .thumbnail-stage .template-region.rtmp video { width: 100%; height: 100%; object-fit: contain; display: block; pointer-events: none; }',
' .thumbnail-stage .template-region.text { color: #fff; display: block; padding: 0; text-align: left; white-space: normal; word-break: break-word; line-height: 1.35; }',
' .thumbnail-stage .template-region.text .template-region-text-scale { display: block; transform-origin: top left; width: 100%; height: 100%; white-space: normal; word-break: break-word; line-height: 1.35; text-align: left; overflow: hidden; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * { margin: 0; }',
' .thumbnail-stage .template-region.text .template-region-text-scale > * + * { margin-top: 0.5em; }',
' .thumbnail-stage .template-region.text .template-region-text-scale ul,',
' .thumbnail-stage .template-region.text .template-region-text-scale ol { padding-left: 1.2em; }',
' .thumbnail-stage .template-region-rtmp-placeholder { position: absolute; inset: 0; }',
' .thumbnail-stage .template-region-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>',
' <body>',
' <div class="thumbnail-stage">',
' <div class="template-stage" style="background-color:' + escapeHtml(backgroundColor) + ';">',
backgroundImagePath ? ' <img class="template-background" src="' + escapeHtml(backgroundImagePath) + '" alt="" />' : '',
regionsHtml,
' </div>',
' </div>',
' </body>',
'</html>'
].join('\n');
}
async function launchBrowser() {
let executablePath = SYSTEM_CHROMIUM_PATHS.find(function (candidate) {
return fs.existsSync(candidate);
@@ -304,6 +193,42 @@ async function captureSlideThumbnail(options) {
await fs.promises.mkdir(path.dirname(filePath), { recursive: true });
async function waitForThumbnailRender(page) {
await page.waitForFunction(function () {
return document.readyState === 'complete' && Boolean(document.querySelector('.slide-canvas'));
}, { timeout: 30000 });
await page.waitForFunction(function () {
var canvas = document.querySelector('.slide-canvas');
if (!canvas) {
return false;
}
var images = Array.prototype.slice.call(canvas.querySelectorAll('img'));
return images.every(function (image) {
return image.complete && typeof image.naturalWidth === 'number';
});
}, { timeout: 30000 });
await page.evaluate(async function () {
if (document.fonts && document.fonts.ready) {
try {
await document.fonts.ready;
} catch (_error) {
// Ignore font readiness failures and fall back to the rendered frame.
}
}
});
await page.evaluate(function () {
return new Promise(function (resolve) {
window.requestAnimationFrame(function () {
window.requestAnimationFrame(resolve);
});
});
});
}
const browser = await launchBrowser();
try {
const page = await browser.newPage();
@@ -312,12 +237,11 @@ async function captureSlideThumbnail(options) {
const previewUrl = baseUrl + previewPath;
await page.setExtraHTTPHeaders(createRequestAuthHeaders({
method: 'GET',
pathname: previewPath,
body: {}
pathname: previewPath
}));
await page.setViewport(PLAYER_VIEWPORT);
await page.goto(previewUrl, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForSelector('.slide-canvas', { timeout: 30000, visible: true });
await waitForThumbnailRender(page);
const canvas = await page.$('.slide-canvas');
if (!canvas) {
throw new Error('Player render did not produce a slide canvas.');
+57 -5
View File
@@ -15,6 +15,7 @@ function createUploadSyncService(options) {
const playerSnapshotCache = options && options.playerSnapshotCache;
const notifyPlayerScreens = options && options.notifyPlayerScreens;
const backgroundTaskQueue = options && options.backgroundTaskQueue;
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
let playerUploadSyncMode = null;
let playerUploadSyncModePromise = null;
@@ -40,7 +41,12 @@ function createUploadSyncService(options) {
cb(null, `${stamp}${safeExt}`);
}
});
return multer({ storage });
return multer({
storage: storage,
limits: {
fileSize: MAX_UPLOAD_BYTES
}
});
}
function normalizeUploadReference(uploadPath) {
@@ -176,6 +182,51 @@ function createUploadSyncService(options) {
}
}
async function collectUploadPathsFromDirectory(uploadDir) {
const normalizedUploadDir = normalizeUploadRoot(uploadDir);
if (!normalizedUploadDir) {
return [];
}
const uploadPaths = [];
async function walkDirectory(currentDir, relativeDir) {
let entries = [];
try {
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
} catch (error) {
if (error && error.code !== 'ENOENT') {
console.warn('Unable to read upload directory:', currentDir, error);
}
return;
}
for (const entry of entries) {
const entryName = String(entry && entry.name || '').trim();
if (!entryName || entryName === '.' || entryName === '..') {
continue;
}
const nextRelativePath = relativeDir ? path.posix.join(relativeDir, entryName) : entryName;
const nextAbsolutePath = path.join(currentDir, entryName);
if (entry.isDirectory && entry.isDirectory()) {
await walkDirectory(nextAbsolutePath, nextRelativePath);
continue;
}
if (entry.isFile && !entry.isFile()) {
continue;
}
uploadPaths.push('/media/uploads/' + nextRelativePath.replace(/\\/g, '/'));
}
}
await walkDirectory(normalizedUploadDir, '');
return uploadPaths;
}
async function getPlayerUploadSyncMode(localUploadDir) {
if (playerUploadSyncMode) {
return playerUploadSyncMode;
@@ -576,6 +627,10 @@ function createUploadSyncService(options) {
if (mode === 'playlist') {
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
@@ -583,10 +638,6 @@ function createUploadSyncService(options) {
}));
}
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
if (refreshTargets.ready.length) {
@@ -639,6 +690,7 @@ function createUploadSyncService(options) {
collectUploadReferencesFromPayload: collectUploadReferencesFromPayload,
countUploadReferences: countUploadReferences,
removeUnusedUploadFiles: removeUnusedUploadFiles,
collectUploadPathsFromDirectory: collectUploadPathsFromDirectory,
getPlayerUploadSyncMode: getPlayerUploadSyncMode,
shouldMirrorUploads: shouldMirrorUploads,
queuePlayerUploadSync: queuePlayerUploadSync,
+2 -1
View File
@@ -33,7 +33,8 @@ module.exports = {
renderCanvasSizesPage: require(routePath('signage', 'canvas-sizes', 'list')),
renderCanvasSizeFormPage: require(routePath('signage', 'canvas-sizes', 'add')),
renderCanvasSizeEditPage: require(routePath('signage', 'canvas-sizes', 'edit')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')),
renderBackgroundTasksPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require(routePath('settings', 'background-tasks-page')).renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
renderRbacPage: require(routePath('settings', 'rbac', 'list')),
renderRbacAddPage: require(routePath('settings', 'rbac', 'add')),
+315 -6
View File
@@ -759,6 +759,13 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: block;
}
.slide-preview-video {
width: 100%;
height: 100%;
object-fit: contain;
display: block;
}
.slide-preview-background {
width: 100%;
height: 100%;
@@ -787,6 +794,246 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
z-index: 0;
}
.slide-preview-popup-body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
color: #fff;
font-family: Arial, sans-serif;
}
.slide-preview-popup-stage {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
background: #111;
}
.slide-preview-popup-canvas {
position: absolute;
top: 0;
left: 0;
transform-origin: top left;
will-change: transform;
}
.slide-preview-popup-canvas,
.slide-preview-popup-stage {
box-sizing: border-box;
}
.slide-image-cropper-frame {
position: relative;
width: 100%;
height: 560px;
min-height: 360px;
max-height: 70vh;
overflow: hidden;
border: 0;
border-radius: 0;
background: var(--bs-body-bg);
}
.slide-image-cropper-frame img {
display: block;
max-width: 100%;
max-height: 65vh;
}
.slide-image-cropper-frame > .cropper-container {
width: 100% !important;
height: 100% !important;
max-width: 100% !important;
max-height: 100% !important;
display: block;
}
.slide-image-cropper-frame > .cropper-container img {
max-width: none !important;
max-height: none !important;
}
.slide-image-cropper-frame .cropper-container,
.slide-image-cropper-frame .cropper-canvas,
.slide-image-cropper-frame .cropper-wrap-box,
.slide-image-cropper-frame .cropper-crop-box,
.slide-image-cropper-frame .cropper-view-box,
.slide-image-cropper-frame .cropper-face {
border-radius: 0 !important;
}
.slide-image-cropper-frame .cropper-wrap-box,
.slide-image-cropper-frame .cropper-crop-box {
border: 0 !important;
}
.slide-image-cropper-toolbar .btn {
white-space: nowrap;
}
.slide-image-region-preview-box {
display: grid;
gap: 0;
justify-items: end;
}
.slide-image-region-preview-shell {
position: relative;
display: block;
width: 100%;
max-width: 288px;
height: 176px;
cursor: pointer;
}
.slide-image-region-preview-shell:focus-visible,
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
opacity: 1;
transform: translate(-50%, -50%) scale(1);
}
.slide-image-region-preview-remove {
position: absolute;
top: 50%;
left: 50%;
display: inline-flex;
align-items: center;
justify-content: center;
width: 4rem;
height: 4rem;
border-radius: 999px;
background: rgba(33, 37, 41, 0.82);
color: #fff;
opacity: 0;
transform: translate(-50%, -50%) scale(0.92);
transition: opacity 0.15s ease, transform 0.15s ease, background-color 0.15s ease;
pointer-events: none;
}
.slide-image-region-preview-shell:hover .slide-image-region-preview-remove,
.slide-image-region-preview-shell:focus-visible .slide-image-region-preview-remove {
background: rgba(var(--bs-danger-rgb), 0.92);
}
.slide-image-region-preview-remove i {
font-size: 1.5rem;
line-height: 1;
}
.slide-image-region-upload-zone {
flex: 1 1 auto;
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 0.9rem;
min-height: 176px;
padding: 1rem 1.1rem;
border: 1px dashed var(--bs-border-color);
border-radius: 0;
background: var(--bs-tertiary-bg);
color: var(--bs-body-color);
cursor: pointer;
transition: border-color 0.15s ease, background-color 0.15s ease, box-shadow 0.15s ease;
}
.slide-image-region-upload-zone-content,
.slide-image-region-upload-zone-progress {
display: contents;
}
.slide-image-region-upload-zone-progress {
display: none;
grid-column: 1 / -1;
gap: 0.5rem;
}
.slide-image-region-upload-zone-progress-label {
font-weight: 600;
color: var(--bs-body-color);
}
.slide-image-region-upload-zone-progress-bar {
width: 100%;
height: 0.85rem;
}
.slide-image-region-upload-zone-limit {
color: var(--bs-danger);
font-size: 0.75rem;
font-weight: 600;
}
.slide-image-region-upload-zone.is-uploading {
grid-template-columns: 1fr;
pointer-events: none;
cursor: progress;
opacity: 0.95;
}
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-content {
display: none;
}
.slide-image-region-upload-zone.is-uploading .slide-image-region-upload-zone-progress {
display: grid;
}
.slide-image-region-upload-zone:hover,
.slide-image-region-upload-zone:focus-within,
.slide-image-region-upload-zone.is-dragover {
border-color: var(--bs-primary);
background: var(--bs-secondary-bg);
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.12);
}
.slide-image-region-upload-zone-icon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2.5rem;
height: 2.5rem;
font-size: 1.25rem;
color: var(--bs-primary);
}
.slide-image-region-upload-zone-copy {
display: grid;
gap: 0.2rem;
}
.slide-image-region-upload-zone-copy span {
color: var(--bs-secondary-color);
font-size: 0.875rem;
}
.slide-image-region-preview {
display: block;
width: 100%;
max-width: 288px;
height: 176px;
object-fit: contain;
border-radius: 0;
background: var(--bs-tertiary-bg);
border: 0;
}
.slide-image-region-preview-shell video.slide-image-region-preview {
pointer-events: none;
}
.slide-image-region-preview-empty {
display: flex;
align-items: center;
justify-content: center;
border: 0;
color: var(--bs-secondary-color);
font-size: 0.8rem;
}
.template-designer-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 20rem;
@@ -984,7 +1231,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.region-info-card .card-title {
@@ -1265,6 +1511,19 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
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;
}
@@ -1322,6 +1581,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
padding: 0 0.9rem 0.6rem;
}
.playlist-slide-picker-filter-button {
white-space: nowrap;
}
.playlist-slide-picker-meta {
display: none;
}
@@ -1335,7 +1598,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
opacity: 0;
background: rgba(var(--bs-body-bg-rgb), 0.88);
border-radius: 999px;
padding: 0.2rem 0.3rem;
padding: 0.2rem 0.45rem;
box-shadow: 0 0.2rem 0.5rem rgba(0, 0, 0, 0.12);
}
@@ -1345,6 +1608,17 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
.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 {
@@ -1416,6 +1690,34 @@ td[data-label="Actions"] > div {
min-width: 6rem;
}
.playlist-duration-input::-webkit-outer-spin-button,
.playlist-duration-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.playlist-duration-input {
-moz-appearance: textfield;
appearance: textfield;
}
.playlist-duration-field {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-start;
gap: 0.5rem;
text-align: left;
}
.playlist-use-video-duration {
white-space: nowrap;
}
td[data-label="Duration"] {
text-align: left;
}
table.table > :not(caption) > * > * {
vertical-align: middle;
}
@@ -1438,8 +1740,9 @@ table.table > :not(caption) > * > * {
.playlist-slide-thumb {
flex: 0 0 auto;
width: 5.5rem;
height: 3.5rem;
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);
@@ -1456,13 +1759,19 @@ table.table > :not(caption) > * > * {
}
.playlist-slide-thumb-placeholder {
display: inline-flex;
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);
font-size: 1.1rem;
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 {
@@ -1,4 +1,4 @@
(function () {
(function () {
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
function formatDays(daysValue) {
@@ -308,6 +308,7 @@
var addSlideModal = document.getElementById('playlist-add-slide-modal');
var addSlideGrid = document.getElementById('playlist-slide-picker-grid');
var addSlideSearch = document.getElementById('playlist-slide-picker-search');
var addSlideShowAssigned = document.getElementById('playlist-slide-picker-show-assigned');
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');
@@ -316,6 +317,9 @@
var slidePickerCards = [];
var slidePickerSelection = new Set();
var slidePickerSlides = [];
var videoDurationCache = Object.create(null);
var lastDurationPointerDown = null;
var lastDurationPointerUp = null;
if (!tbody || !form) {
return;
@@ -408,6 +412,7 @@
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';
@@ -415,6 +420,7 @@
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;
@@ -427,6 +433,11 @@
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');
@@ -436,6 +447,16 @@
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;
@@ -451,19 +472,31 @@
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 matches = !query || searchText.indexOf(query) !== -1;
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) {
addSlideEmpty.textContent = query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
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);
}
@@ -474,6 +507,8 @@
if (addSlideConfirm) {
addSlideConfirm.disabled = slidePickerSelection.size === 0;
}
setShowAssignedState(showAssignedSlides);
}
function toggleCardSelection(card) {
@@ -512,7 +547,14 @@
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 || ''),
showVideoDurationButton: slide.showVideoDurationButton,
videoSourcePath: slide.videoSourcePath,
videoDurationSeconds: slide.videoDurationSeconds,
media_type: slide.media_type,
media_path: slide.media_path,
title: slide.title || 'Slide',
duration_seconds: 10,
schedule_mode: 'always',
@@ -626,7 +668,10 @@
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);
@@ -736,6 +781,12 @@
var row = document.createElement('tr');
var rowKey = values.row_key || ('new-' + Date.now() + '-' + Math.random().toString(36).slice(2));
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>';
@@ -743,6 +794,14 @@
row.setAttribute('data-row-key', rowKey);
row.setAttribute('data-slide-id', String(values.slide_id));
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
row.setAttribute('data-media-type', String(values.media_type || ''));
row.setAttribute('data-media-path', String(values.media_path || ''));
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
var durationActionMarkup = Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
? '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (values.useVideoDuration ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (values.useVideoDuration ? 'true' : 'false') + '">' + (values.useVideoDuration ? 'Use Video Duration' : 'Use video duration') + '</button>'
: '';
row.innerHTML = '' +
'<td class="playlist-order-cell" data-label="Order">' +
'<div class="playlist-order-cell-inner">' +
@@ -752,7 +811,7 @@
'</td>' +
'<td data-label="Slide">' +
'<div class="playlist-slide-cell">' +
'<div class="playlist-slide-thumb" aria-hidden="true">' + thumbnailMarkup + '</div>' +
'<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>' +
@@ -765,7 +824,11 @@
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
'</td>' +
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" /></td>' +
'<td><div class="playlist-duration-field"><input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
durationActionMarkup +
'</div></td>' +
'<td><div class="actions playlist-item-actions">' +
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
@@ -773,9 +836,202 @@
return row;
}
function loadVideoDuration(mediaPath) {
var cacheKey = String(mediaPath || '').trim();
var cachedPromise;
if (!cacheKey) {
return Promise.reject(new Error('No video source is available for this slide.'));
}
cachedPromise = videoDurationCache[cacheKey];
if (cachedPromise) {
return cachedPromise;
}
cachedPromise = new Promise(function (resolve, reject) {
var video = document.createElement('video');
var timeoutId = window.setTimeout(function () {
cleanup();
reject(new Error('Timed out loading the video metadata.'));
}, 15000);
function cleanup() {
window.clearTimeout(timeoutId);
video.removeAttribute('src');
video.load();
if (video.parentNode) {
video.parentNode.removeChild(video);
}
}
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.style.position = 'absolute';
video.style.left = '-9999px';
video.style.top = '0';
video.style.width = '1px';
video.style.height = '1px';
video.style.opacity = '0';
video.style.pointerEvents = 'none';
video.addEventListener('loadedmetadata', function () {
var duration = Number(video.duration);
cleanup();
if (Number.isFinite(duration) && duration > 0) {
resolve(Math.round(duration * 1000) / 1000);
} else {
reject(new Error('The video duration could not be read.'));
}
}, { once: true });
video.addEventListener('error', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
video.addEventListener('abort', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
document.body.appendChild(video);
video.src = cacheKey;
video.load();
}).catch(function (error) {
delete videoDurationCache[cacheKey];
throw error;
});
videoDurationCache[cacheKey] = cachedPromise;
return cachedPromise;
}
function setVideoDurationToggleState(row, button, isPressed) {
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
var flag = row ? row.querySelector('[name="use_video_duration[]"]') : null;
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) {
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
}
if (button) {
button.classList.toggle('active', Boolean(isPressed));
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
button.textContent = isPressed ? 'Use Video Duration' : 'Use video duration';
}
if (input) {
if (!isPressed && row) {
var previousValue = String(row.getAttribute('data-video-duration-previous-value') || '').trim();
if (previousValue) {
input.value = previousValue;
}
row.removeAttribute('data-video-duration-previous-value');
}
input.disabled = Boolean(isPressed);
input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed));
}
if (flag) {
flag.value = isPressed ? '1' : '0';
}
if (row) {
row.setAttribute('data-use-video-duration', isPressed ? 'true' : 'false');
}
if (!row || !input) {
return;
}
if (isPressed) {
if (!mirror) {
mirror = document.createElement('input');
mirror.type = 'hidden';
mirror.setAttribute('data-video-duration-mirror', '');
input.parentNode.insertBefore(mirror, input.nextSibling);
}
mirror.name = input.name;
mirror.value = input.value;
if (input.getAttribute('form')) {
mirror.setAttribute('form', input.getAttribute('form'));
}
} else if (mirror && mirror.parentNode) {
mirror.parentNode.removeChild(mirror);
}
}
async function applyVideoDurationToRow(row, button) {
var mediaType = String(row && row.getAttribute('data-media-type') || '').trim().toLowerCase();
var mediaPath = String(row && (row.getAttribute('data-video-source-path') || row.getAttribute('data-media-path')) || '').trim();
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
var isVideoRow = mediaType === 'video' || Boolean(mediaPath);
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
if (!isVideoRow || !input) {
return;
}
if (button && button.getAttribute('aria-pressed') === 'true') {
setVideoDurationToggleState(row, button, false);
return;
}
setVideoDurationToggleState(row, button, true);
if (Number.isFinite(storedDuration) && storedDuration > 0) {
input.value = String(storedDuration);
var existingMirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
if (existingMirror) {
existingMirror.value = input.value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
return;
}
if (button) {
button.disabled = true;
button.textContent = 'Loading...';
}
try {
var durationSeconds = await loadVideoDuration(mediaPath);
if (row) {
row.setAttribute('data-video-duration-seconds', String(durationSeconds));
}
input.value = String(durationSeconds);
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
if (mirror) {
mirror.value = input.value;
}
input.dispatchEvent(new Event('input', { bubbles: true }));
input.dispatchEvent(new Event('change', { bubbles: true }));
} catch (error) {
setVideoDurationToggleState(row, button, false);
alert(error && error.message ? error.message : 'Unable to read the video duration.');
} finally {
if (button) {
button.disabled = false;
button.textContent = button.getAttribute('aria-pressed') === 'true' ? 'Use Video Duration' : 'Use video duration';
}
}
}
tbody.addEventListener('click', function (event) {
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
event.stopPropagation();
return;
}
var removeButton = event.target.closest('[data-playlist-remove-row]');
var scheduleButton = event.target.closest('[data-schedule-config]');
var durationButton = event.target.closest('[data-use-video-duration]');
var row = event.target.closest('tr[data-playlist-slide-row]');
if (!row) {
@@ -794,6 +1050,102 @@
if (typeof window.openScheduleModal === 'function') {
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || ''));
}
return;
}
if (durationButton) {
var rowKey = row.getAttribute('data-row-key');
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
if (event.detail > 0 && (pressedOnInput || !releasedOnButton)) {
lastDurationPointerDown = null;
lastDurationPointerUp = null;
event.preventDefault();
return;
}
event.preventDefault();
void applyVideoDurationToRow(row, durationButton);
}
lastDurationPointerDown = null;
lastDurationPointerUp = null;
});
tbody.addEventListener('mousedown', function (event) {
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null;
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null;
if (row && !dragHandle) {
event.stopPropagation();
}
if (durationInput) {
lastDurationPointerDown = {
type: 'input',
rowKey: String(event.target.closest('tr[data-playlist-slide-row]') && event.target.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
};
event.stopPropagation();
return;
}
if (durationToggle) {
lastDurationPointerDown = {
type: 'button',
rowKey: String(durationToggle.closest('tr[data-playlist-slide-row]') && durationToggle.closest('tr[data-playlist-slide-row]').getAttribute('data-row-key') || '')
};
return;
}
lastDurationPointerDown = null;
});
tbody.addEventListener('mouseup', function (event) {
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
var durationToggle = event.target && event.target.closest ? event.target.closest('.playlist-use-video-duration') : null;
var row = event.target && event.target.closest ? event.target.closest('tr[data-playlist-slide-row]') : null;
if (durationInput) {
lastDurationPointerUp = {
type: 'input',
rowKey: String(row && row.getAttribute('data-row-key') || '')
};
return;
}
if (durationToggle) {
lastDurationPointerUp = {
type: 'button',
rowKey: String(row && row.getAttribute('data-row-key') || '')
};
return;
}
lastDurationPointerUp = null;
});
tbody.addEventListener('input', function (event) {
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
var editedRow = event.target.closest('tr[data-playlist-slide-row]');
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
if (editedRow) {
editedRow.setAttribute('data-use-video-duration', useVideoDuration ? 'true' : 'false');
}
if (useVideoDuration && editedMirror) {
editedMirror.value = event.target.value;
}
if (editedMirror && !useVideoDuration) {
editedMirror.parentNode.removeChild(editedMirror);
}
markPlaylistDirty();
}
});
@@ -839,6 +1191,14 @@
addSlideSearch.addEventListener('input', syncSlidePickerState);
}
if (addSlideShowAssigned) {
addSlideShowAssigned.addEventListener('click', function () {
var nextState = addSlideShowAssigned.getAttribute('aria-pressed') !== 'true';
setShowAssignedState(nextState);
syncSlidePickerState();
});
}
if (addSlideConfirm) {
addSlideConfirm.addEventListener('click', function () {
addSelectedSlides();
+1 -39
View File
@@ -4,7 +4,7 @@
var stateNode = document.getElementById('background-tasks-state');
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;
}
@@ -174,26 +174,6 @@
};
}
function findFilterLink(target) {
if (!target || !target.closest) {
return null;
}
var link = target.closest('a[data-background-tasks-nav]');
if (!link) {
return null;
}
var href = String(link.getAttribute('href') || '').trim();
if (!href || href === '#') {
return null;
}
return {
href: href
};
}
function initPaginationNavigation() {
document.addEventListener('click', function (event) {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) {
@@ -217,24 +197,6 @@
loadPage(nextUrl.toString(), { sectionNames: [paginationLink.sectionName] });
return;
}
var filterLink = findFilterLink(event.target);
if (!filterLink) {
return;
}
var filterUrl = getPageUrl(filterLink.href);
if (!filterUrl) {
return;
}
if (filterUrl.pathname === window.location.pathname && filterUrl.search === window.location.search && filterUrl.hash === window.location.hash) {
event.preventDefault();
return;
}
event.preventDefault();
loadPage(filterUrl.toString(), { sectionNames: ['tasks'] });
}, true);
window.addEventListener('popstate', function () {
+721 -70
View File
@@ -58,6 +58,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var openPreviewPopupButton = document.getElementById('open-preview-popup');
var editorInstances = new Map();
var submitting = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
var uploadVideoMaxLabel = '1 GB';
var videoDurationCache = Object.create(null);
var previewRenderFrame = 0;
var previewPopupWindow = null;
var previewPopupRenderFrame = 0;
@@ -253,6 +258,12 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return current && current.value !== undefined ? current.value : '';
}
function getCurrentRegionVideoDuration(region) {
var current = existingContent[region.region_key] || {};
var duration = Math.round(Number(current.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? String(duration) : '';
}
function normalizeEditorData(value) {
return String(value || '');
}
@@ -456,6 +467,47 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return '<iframe class="slide-preview-html-frame" sandbox="" scrolling="no" srcdoc="<!doctype html><html><head><style>html,body{margin:0;width:100%;height:100%;overflow:hidden;background:transparent;}</style></head><body>' + escapeHtml(html) + '</body></html>" title="HTML preview" loading="eager" style="width:100%;height:100%;border:0;display:block;background:transparent;overflow:hidden;"></iframe>';
}
function startPreviewVideoPlayback(root) {
if (!root) {
return;
}
var videos = root.querySelectorAll('video.slide-preview-video');
Array.prototype.forEach.call(videos, function (video) {
if (!video) {
return;
}
video.autoplay = true;
video.loop = true;
video.muted = true;
video.playsInline = true;
function tryPlay() {
try {
video.load();
} catch (_error) {
// Ignore load failures and try to play anyway.
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
}
if (video.readyState >= 2) {
tryPlay();
return;
}
video.addEventListener('canplay', tryPlay, { once: true });
video.addEventListener('loadedmetadata', tryPlay, { once: true });
});
}
function destroyEditors() {
editorInstances.forEach(function (editor) {
if (editor && typeof editor.destroy === 'function') {
@@ -765,7 +817,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
}
function clearImagePreviewUrls() {
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
@@ -773,18 +825,370 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
}
function getRegionMediaType(card) {
if (card && card.dataset && String(card.dataset.regionMediaType || '').trim()) {
return String(card.dataset.regionMediaType || '').trim() === 'video' ? 'video' : 'image';
}
var regionTypeInput = card && card.querySelector ? card.querySelector('input[type="hidden"][name="region_type[]"]') : null;
return regionTypeInput && String(regionTypeInput.value || '').trim() === 'video' ? 'video' : 'image';
}
function getRegionMediaPrefix(mediaType) {
return String(mediaType || '').trim() === 'video' ? 'region_video_' : 'region_image_';
}
function getRegionExistingMediaPrefix(mediaType) {
return String(mediaType || '').trim() === 'video' ? 'existing_region_video_' : 'existing_region_image_';
}
function getRegionExistingVideoDurationName(regionId) {
return 'existing_region_video_duration_' + regionId;
}
function getRegionVideoDurationHiddenInput(regionId) {
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
return card ? card.querySelector('input[type="hidden"][name="' + getRegionExistingVideoDurationName(regionId) + '"]') : null;
}
function getRegionUploadZone(input) {
return input ? input.closest('[data-region-upload-zone]') : null;
}
function getRegionUploadHiddenValue(input) {
var mediaType = input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
var regionId = input && input.name ? input.name.replace(/^region_(?:image|video)_/, '') : '';
var card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
return hidden ? hidden.value : '';
}
function getRegionUploadProgressNode(zone) {
return zone ? zone.querySelector('[data-region-upload-progress]') : null;
}
function getRegionUploadProgressBar(zone) {
return zone ? zone.querySelector('[data-region-upload-progress-bar]') : null;
}
function getRegionUploadProgressText(zone) {
return zone ? zone.querySelector('[data-region-upload-progress-text]') : null;
}
function setRegionUploadProgress(zone, percent, text, isIndeterminate) {
var progress = getRegionUploadProgressNode(zone);
var bar = getRegionUploadProgressBar(zone);
var progressText = getRegionUploadProgressText(zone);
var normalizedPercent = Math.max(0, Math.min(100, Math.round(Number(percent || 0))));
if (!progress || !bar) {
return;
}
if (progressText) {
progressText.textContent = text || 'Uploading...';
}
progress.hidden = false;
bar.style.width = (isIndeterminate ? 100 : normalizedPercent) + '%';
bar.textContent = isIndeterminate ? 'Uploading...' : normalizedPercent + '%';
bar.setAttribute('aria-valuenow', String(isIndeterminate ? 100 : normalizedPercent));
bar.classList.toggle('progress-bar-striped', Boolean(isIndeterminate));
bar.classList.toggle('progress-bar-animated', Boolean(isIndeterminate));
}
function setRegionUploadState(input, isUploading, percent, text, isIndeterminate) {
var zone = getRegionUploadZone(input);
var progress = getRegionUploadProgressNode(zone);
if (!zone || !progress) {
return;
}
zone.classList.toggle('is-uploading', Boolean(isUploading));
zone.setAttribute('aria-busy', isUploading ? 'true' : 'false');
input.disabled = Boolean(isUploading);
if (isUploading) {
setRegionUploadProgress(zone, percent, text, isIndeterminate);
return;
}
progress.hidden = true;
if (input) {
input.disabled = false;
}
}
function isRegionImageFileWithinLimit(file) {
return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes;
}
function getRegionMediaTypeFromInput(input) {
return input && input.name && input.name.indexOf('region_video_') === 0 ? 'video' : 'image';
}
function getRegionMediaUploadLimitBytes(mediaType) {
return String(mediaType || '').trim() === 'video' ? uploadVideoMaxBytes : uploadMaxBytes;
}
function getRegionMediaUploadLimitLabel(mediaType) {
return String(mediaType || '').trim() === 'video' ? uploadVideoMaxLabel : uploadMaxLabel;
}
function isRegionMediaFileWithinLimit(file, mediaType) {
return Boolean(file) && Number(file.size || 0) <= getRegionMediaUploadLimitBytes(mediaType);
}
function rejectOversizeRegionImage(input, file) {
return rejectOversizeRegionMedia(input, file, 'image');
}
function rejectOversizeRegionMedia(input, file, mediaType) {
var zone = getRegionUploadZone(input);
var hiddenValue = getRegionUploadHiddenValue(input);
var limitLabel = getRegionMediaUploadLimitLabel(mediaType || getRegionMediaTypeFromInput(input));
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + limitLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + limitLabel + ' or smaller.');
}
input.value = '';
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
renderRegionMediaPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
if (file) {
return false;
}
return false;
}
function setRegionImageInputFile(input, file) {
return setRegionMediaInputFile(input, file);
}
function setRegionMediaInputFile(input, file) {
if (!input || !file) {
return;
}
var mediaType = getRegionMediaTypeFromInput(input);
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
rejectOversizeRegionMedia(input, file, mediaType);
return;
}
var dataTransfer = new DataTransfer();
dataTransfer.items.add(file);
input.files = dataTransfer.files;
input.dispatchEvent(new Event('change', { bubbles: true }));
}
function renderImageRegionPreview(card, value) {
function uploadRegionImageFile(input, file) {
return uploadRegionMediaFile(input, file);
}
function uploadRegionMediaFile(input, file) {
if (!input || !file) {
return Promise.resolve(null);
}
var mediaType = getRegionMediaTypeFromInput(input);
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
rejectOversizeRegionMedia(input, file, mediaType);
return Promise.resolve(null);
}
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
var zone = getRegionUploadZone(input);
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
var previousNeedsCleanup = String(input.dataset.uploadedNeedsCleanup || '') === '1';
input.dataset.uploadToken = uploadToken;
setRegionUploadState(input, true, 0, 'Uploading...', true);
return new Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
var formData = new FormData();
formData.append('file', file, file.name || 'upload');
xhr.open('POST', '/slides/uploads', true);
xhr.responseType = 'text';
xhr.withCredentials = true;
xhr.setRequestHeader('X-Requested-With', 'XMLHttpRequest');
xhr.setRequestHeader('Accept', 'application/json, text/plain, */*');
xhr.upload.onprogress = function (event) {
if (input.dataset.uploadToken !== uploadToken) {
return;
}
if (!event || !event.lengthComputable || !event.total) {
setRegionUploadProgress(zone, 100, 'Uploading...', true);
return;
}
var percent = Math.round((event.loaded / event.total) * 100);
setRegionUploadProgress(zone, percent, 'Uploading ' + percent + '%', false);
};
xhr.onload = function () {
if (input.dataset.uploadToken !== uploadToken) {
resolve(null);
return;
}
var response = {
ok: xhr.status >= 200 && xhr.status < 300,
status: xhr.status,
statusText: xhr.statusText,
responseText: xhr.responseText || ''
};
if (!response.ok) {
var error = new Error(response.responseText || 'Unable to upload media.');
error.status = response.status;
error.responseText = response.responseText;
reject(error);
return;
}
var payload = {};
try {
payload = JSON.parse(response.responseText || '{}') || {};
} catch (_error) {
payload = {};
}
var uploadedPath = String(payload.path || '').trim();
if (!uploadedPath) {
reject(new Error('Unable to upload media.'));
return;
}
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (hidden) {
hidden.value = uploadedPath;
}
input.dataset.uploadedPath = uploadedPath;
input.dataset.uploadedNeedsCleanup = '1';
input.value = '';
input.setCustomValidity('');
renderRegionMediaPreview(card, uploadedPath);
void syncVideoRegionDuration(card, uploadedPath);
templateSelectorLock.markEdited();
requestPreviewRender();
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
queueUploadCleanup([previousUploadedPath]);
}
setRegionUploadState(input, false);
resolve(payload);
};
xhr.onerror = function () {
if (input.dataset.uploadToken !== uploadToken) {
resolve(null);
return;
}
reject(new Error('Unable to upload media.'));
};
xhr.onabort = function () {
if (input.dataset.uploadToken !== uploadToken) {
resolve(null);
return;
}
reject(new Error('Unable to upload media.'));
};
xhr.onloadend = function () {
if (input.dataset.uploadToken === uploadToken) {
delete input.dataset.uploadToken;
setRegionUploadState(input, false);
}
};
xhr.send(formData);
}).catch(function (error) {
if (input.dataset.uploadToken === uploadToken) {
delete input.dataset.uploadToken;
setRegionUploadState(input, false);
}
throw error;
});
}
function getPendingUploadCleanupPaths() {
return getRegionMediaInputs().map(function (input) {
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
return null;
}
return String(input.dataset.uploadedPath || '').trim();
}).filter(Boolean);
}
function clearPendingUploadCleanupPaths() {
getRegionMediaInputs().forEach(function (input) {
if (!input) {
return;
}
delete input.dataset.uploadedNeedsCleanup;
});
}
function queueUploadCleanup(uploadPaths) {
var normalizedPaths = Array.from(new Set((uploadPaths || []).map(function (value) {
return String(value || '').trim();
}).filter(Boolean)));
if (!normalizedPaths.length) {
return;
}
var payload = JSON.stringify({ uploadPaths: normalizedPaths });
var url = '/slides/uploads/cleanup';
if (navigator.sendBeacon) {
try {
navigator.sendBeacon(url, new Blob([payload], { type: 'application/json' }));
return;
} catch (_error) {
// Fall back to fetch below.
}
}
fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: payload,
credentials: 'same-origin',
keepalive: true
}).catch(function () {
// Best-effort cleanup only.
});
}
function renderRegionMediaPreview(card, value) {
if (!card) {
return;
}
@@ -794,9 +1198,158 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return;
}
var mediaType = getRegionMediaType(card);
var isVideo = mediaType === 'video';
previewBox.innerHTML = value
? '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
? '<div class="slide-image-region-preview-shell" data-remove-' + (isVideo ? 'region-video' : 'region-image') + '="' + escapeHtml(card.getAttribute('data-region-id') || '') + '" role="button" tabindex="0" aria-label="Remove ' + (isVideo ? 'video' : 'image') + '">' +
(isVideo
? '<video class="slide-image-region-preview" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>'
: '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />') +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No ' + (isVideo ? 'video' : 'image') + '</div>';
if (isVideo) {
window.requestAnimationFrame(function () {
var video = previewBox.querySelector('video.slide-image-region-preview');
if (!video) {
return;
}
try {
video.load();
} catch (_error) {
// Ignore load failures; play() will retry if the browser allows it.
}
var playPromise = video.play && video.play();
if (playPromise && typeof playPromise.catch === 'function') {
playPromise.catch(function () {
return null;
});
}
});
}
}
function loadVideoDuration(mediaPath) {
var cacheKey = String(mediaPath || '').trim();
var cachedPromise;
if (!cacheKey) {
return Promise.reject(new Error('No video source is available for this slide.'));
}
cachedPromise = videoDurationCache[cacheKey];
if (cachedPromise) {
return cachedPromise;
}
cachedPromise = new Promise(function (resolve, reject) {
var video = document.createElement('video');
var timeoutId = window.setTimeout(function () {
cleanup();
reject(new Error('Timed out loading the video metadata.'));
}, 15000);
function cleanup() {
window.clearTimeout(timeoutId);
video.removeAttribute('src');
video.load();
if (video.parentNode) {
video.parentNode.removeChild(video);
}
}
video.preload = 'metadata';
video.muted = true;
video.playsInline = true;
video.setAttribute('playsinline', '');
video.setAttribute('muted', '');
video.style.position = 'absolute';
video.style.left = '-9999px';
video.style.top = '0';
video.style.width = '1px';
video.style.height = '1px';
video.style.opacity = '0';
video.style.pointerEvents = 'none';
video.addEventListener('loadedmetadata', function () {
var duration = Number(video.duration);
cleanup();
if (Number.isFinite(duration) && duration > 0) {
resolve(Math.max(1, Math.round(duration)));
} else {
reject(new Error('The video duration could not be read.'));
}
}, { once: true });
video.addEventListener('error', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
video.addEventListener('abort', function () {
cleanup();
reject(new Error('The video duration could not be read.'));
}, { once: true });
document.body.appendChild(video);
video.src = cacheKey;
video.load();
}).catch(function (error) {
delete videoDurationCache[cacheKey];
throw error;
});
videoDurationCache[cacheKey] = cachedPromise;
return cachedPromise;
}
async function syncVideoRegionDuration(card, source) {
if (!card) {
return;
}
var regionId = card.getAttribute('data-region-id');
var hidden = regionId ? getRegionVideoDurationHiddenInput(regionId) : null;
if (!hidden) {
return;
}
var mediaPath = String(source || '').trim();
if (!mediaPath) {
hidden.value = '';
return;
}
try {
hidden.value = String(await loadVideoDuration(mediaPath));
} catch (_error) {
hidden.value = '';
}
}
function renderImageRegionPreview(card, value) {
return renderRegionMediaPreview(card, value);
}
function getRegionImageInputs() {
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]'));
}
function getRegionMediaInputs() {
return getRegionImageInputs();
}
function hasPendingRegionImageUpload() {
return getRegionImageInputs().some(function (input) {
return Boolean(input && input.files && input.files.length);
});
}
function hasPendingRegionMediaUpload() {
return hasPendingRegionImageUpload();
}
function getRegionBox(region, canvasWidth, canvasHeight) {
@@ -822,23 +1375,11 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<meta charset="utf-8" />' +
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
'<title>Slide preview</title>' +
'<style>' +
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
'.slide-preview-overlay{z-index:2;}' +
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
'</style>' +
'<link rel="stylesheet" href="/assets/css/theme-custom.css" />' +
'</head>' +
'<body>' +
'<div id="popup-preview-stage">' +
'<div id="popup-preview-canvas"></div>' +
'<body class="slide-preview-popup-body">' +
'<div class="slide-preview-popup-stage" id="popup-preview-stage">' +
'<div class="slide-preview-popup-canvas" id="popup-preview-canvas"></div>' +
'</div>' +
'</body>' +
'</html>';
@@ -886,6 +1427,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (popupOverlay) {
popupOverlay.innerHTML = '';
}
startPreviewVideoPlayback(popupCanvas);
return;
}
@@ -907,6 +1449,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
popupEmpty.style.display = 'none';
}
startPreviewVideoPlayback(popupCanvas);
var stageRect = popupStage.getBoundingClientRect();
if (!stageRect.width || !stageRect.height) {
if (previewPopupRenderFrame) {
@@ -1058,6 +1602,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]');
var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
var imageFileInput = card && card.querySelector('input[type="file"][name="region_image_' + region.id + '"]');
var videoHidden = card && card.querySelector('input[type="hidden"][name="existing_region_video_' + region.id + '"]');
var videoFileInput = card && card.querySelector('input[type="file"][name="region_video_' + region.id + '"]');
var webpageInput = card && card.querySelector('input[type="url"][name="region_webpage_' + region.id + '"]');
var rtmpInput = card && card.querySelector('input[type="url"][name="region_rtmp_' + region.id + '"]');
var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
@@ -1070,6 +1616,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var existingApi = existingContent[region.region_key] || {};
var value = region.region_type === 'image'
? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
: region.region_type === 'video'
? ((videoFileInput && videoFileInput.dataset.previewUrl) ? videoFileInput.dataset.previewUrl : (videoHidden ? videoHidden.value : ''))
: region.region_type === 'webpage'
? (webpageInput ? webpageInput.value : '')
: region.region_type === 'rtmp'
@@ -1092,6 +1640,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
: getTextStyleFromCard(card, region);
var content = region.region_type === 'image'
? (value ? '<img class="slide-preview-image" src="' + escapeHtml(value) + '" alt="" />' : '<div class="slide-preview-placeholder">Image</div>')
: region.region_type === 'video'
? (value ? '<video class="slide-preview-video" src="' + escapeHtml(value) + '" autoplay loop muted playsinline preload="metadata"></video>' : '<div class="slide-preview-placeholder">Video</div>')
: region.region_type === 'webpage'
? renderPreviewWebpageRegion(value)
: region.region_type === 'rtmp'
@@ -1116,6 +1666,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
: renderPreviewTextRegion(region, value, style, scale);
var selected = region.region_type === 'image'
? ' slide-preview-image-region'
: region.region_type === 'video'
? ' slide-preview-video-region'
: region.region_type === 'webpage'
? ' slide-preview-webpage-region'
: region.region_type === 'rtmp'
@@ -1130,6 +1682,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
}).join('');
startPreviewVideoPlayback(slidePreviewOverlay);
syncPopupPreview();
}
@@ -1319,7 +1873,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="image">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Image</span>' +
@@ -1329,10 +1883,19 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'<div class="col-12 col-md-8 d-flex flex-column">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_image_' + region.id + '">' +
'<input type="file" id="region_image_' + region.id + '" name="region_image_' + region.id + '" class="visually-hidden" accept="image/*" data-slide-image-cropper-region-ratio="' + escapeHtml(regionRatio) + '" data-slide-image-cropper-region-ratio-label="Region" />' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop an image here or click to upload</strong>' +
'<span>PNG, JPG, GIF, or WebP</span>' +
'<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-cloud-arrow-up" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop an image here or click to upload</strong>' +
'<span>PNG, JPG, GIF, or WebP</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadMaxLabel + ' per file</span>' +
'</span>' +
'</span>' +
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Image upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
'</div>' +
'</span>' +
'</label>' +
'</div>' +
@@ -1352,6 +1915,53 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
'</div>';
}
function renderVideoRegion(region) {
var current = getCurrentRegionValue(region);
var currentDuration = getCurrentRegionVideoDuration(region);
return '' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="video">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Video</span>' +
'</div>' +
'<div class="card-body p-3 d-grid">' +
'<div class="row g-3 align-items-start">' +
'<div class="col-12 col-md-8 d-flex flex-column">' +
'<label class="slide-image-region-upload-zone" data-region-upload-zone="' + region.id + '" for="region_video_' + region.id + '">' +
'<input type="file" id="region_video_' + region.id + '" name="region_video_' + region.id + '" class="visually-hidden" accept="video/*" />' +
'<span class="slide-image-region-upload-zone-content">' +
'<span class="slide-image-region-upload-zone-icon"><i class="bi bi-camera-video" aria-hidden="true"></i></span>' +
'<span class="slide-image-region-upload-zone-copy">' +
'<strong>Drop a video here or click to upload</strong>' +
'<span>MP4, WebM, or Ogg</span>' +
'<span class="slide-image-region-upload-zone-limit">Max ' + uploadVideoMaxLabel + ' per file</span>' +
'</span>' +
'</span>' +
'<span class="slide-image-region-upload-zone-progress" data-region-upload-progress hidden>' +
'<span class="slide-image-region-upload-zone-progress-label" data-region-upload-progress-text>Uploading...</span>' +
'<div class="progress slide-image-region-upload-zone-progress-bar" role="progressbar" aria-label="Video upload progress" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">' +
'<div class="progress-bar bg-danger progress-bar-striped progress-bar-animated" data-region-upload-progress-bar style="width:0%">0%</div>' +
'</div>' +
'</span>' +
'</label>' +
'</div>' +
'<div class="col-12 col-md-4">' +
'<div class="slide-image-region-preview-box">' +
(current
? '<div class="slide-image-region-preview-shell" data-remove-region-video="' + region.id + '" role="button" tabindex="0" aria-label="Remove video">' +
'<video class="slide-image-region-preview" src="' + escapeHtml(current) + '" muted playsinline preload="metadata"></video>' +
'<span class="slide-image-region-preview-remove" aria-hidden="true"><i class="bi bi-trash3" aria-hidden="true"></i></span>' +
'</div>'
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No video</div>') +
'</div>' +
'</div>' +
'</div>' +
'<input type="hidden" name="existing_region_video_' + region.id + '" value="' + escapeHtml(current) + '" />' +
'<input type="hidden" name="' + getRegionExistingVideoDurationName(region.id) + '" value="' + escapeHtml(currentDuration) + '" />' +
'</div>' +
'</div>';
}
function renderTemplate() {
templateSelectorLock.reset();
clearImagePreviewUrls();
@@ -1367,6 +1977,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (region.region_type === 'image') {
return renderImageRegion(region);
}
if (region.region_type === 'video') {
return renderVideoRegion(region);
}
if (region.region_type === 'webpage') {
return renderWebpageRegion(region);
}
@@ -1385,6 +1998,14 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
return renderTextRegion(region);
}).join('') : '<div class="muted">This template has no regions.</div>';
templateFields.querySelectorAll('[data-region-media-type="video"]').forEach(function (card) {
var regionId = card.getAttribute('data-region-id');
var hidden = regionId ? card.querySelector('input[type="hidden"][name="existing_region_video_' + regionId + '"]') : null;
if (hidden && hidden.value) {
void syncVideoRegionDuration(card, hidden.value);
}
});
templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) {
var regionId = holder.getAttribute('data-region-id');
var source = holder.querySelector('.ckeditor-source');
@@ -1492,26 +2113,62 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
});
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
templateFields.querySelectorAll('input[type="file"][name^="region_image_"], input[type="file"][name^="region_video_"]').forEach(function (input) {
input.addEventListener('change', function () {
var mediaType = getRegionMediaTypeFromInput(input);
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
var file = input.files && input.files.length ? input.files[0] : null;
if (file && !isRegionMediaFileWithinLimit(file, mediaType)) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
input.value = '';
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.');
}
renderRegionMediaPreview(card, hidden ? hidden.value : '');
return;
}
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input.files && input.files.length) {
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
if (file) {
input.dataset.previewUrl = URL.createObjectURL(file);
}
var regionId = input.name.replace(/^region_image_/, '');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
renderImageRegionPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
renderRegionMediaPreview(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
void syncVideoRegionDuration(card, input.dataset.previewUrl || (hidden ? hidden.value : ''));
templateSelectorLock.markEdited();
requestPreviewRender();
if (file) {
uploadRegionMediaFile(input, file).catch(function (error) {
if (input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
input.value = '';
if (typeof showToast === 'function') {
showToast(error && error.message ? error.message : 'Unable to upload media.', 'danger');
} else {
window.alert(error && error.message ? error.message : 'Unable to upload media.');
}
renderRegionMediaPreview(card, hidden ? hidden.value : '');
void syncVideoRegionDuration(card, hidden ? hidden.value : '');
requestPreviewRender();
});
}
});
});
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
var regionId = zone.getAttribute('data-region-upload-zone');
var input = zone.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
var input = zone.querySelector('input[type="file"]');
if (!input) {
return;
}
@@ -1531,66 +2188,54 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
var file = files && files.length ? files[0] : null;
if (!file || !/^image\//i.test(file.type || '')) {
var card = zone.closest('[data-region-id]');
var mediaType = getRegionMediaType(card);
if (!file || !(mediaType === 'video' ? /^video\//i.test(file.type || '') : /^image\//i.test(file.type || ''))) {
return;
}
setRegionImageInputFile(input, file);
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
rejectOversizeRegionMedia(input, file, mediaType);
return;
}
setRegionMediaInputFile(input, file);
});
});
templateFields.querySelectorAll('button[data-remove-region-image]').forEach(function (button) {
templateFields.querySelectorAll('[data-remove-region-image], [data-remove-region-video]').forEach(function (button) {
button.addEventListener('click', function () {
var regionId = button.getAttribute('data-remove-region-image');
var regionId = button.getAttribute('data-remove-region-image') || button.getAttribute('data-remove-region-video');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
var mediaType = getRegionMediaType(card);
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="' + getRegionMediaPrefix(mediaType) + regionId + '"]');
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
queueUploadCleanup([uploadPath]);
}
if (input) {
input.value = '';
delete input.dataset.uploadedPath;
delete input.dataset.uploadedNeedsCleanup;
}
if (hidden) {
hidden.value = '';
}
var durationHidden = card ? getRegionVideoDurationHiddenInput(regionId) : null;
if (durationHidden) {
durationHidden.value = '';
}
renderImageRegionPreview(card, '');
renderRegionMediaPreview(card, '');
templateSelectorLock.markEdited();
requestPreviewRender();
});
});
templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
shell.addEventListener('click', function () {
var regionId = shell.getAttribute('data-remove-region-image');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
if (input && input.dataset.previewUrl) {
URL.revokeObjectURL(input.dataset.previewUrl);
delete input.dataset.previewUrl;
}
if (input) {
input.value = '';
}
if (hidden) {
hidden.value = '';
}
renderImageRegionPreview(card, '');
templateSelectorLock.markEdited();
requestPreviewRender();
});
shell.addEventListener('keydown', function (event) {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
shell.click();
}
});
});
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
input.addEventListener('input', function () {
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
@@ -1656,6 +2301,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
});
submitting = true;
try {
if (hasPendingRegionImageUpload()) {
throw new Error('Wait for the image upload to finish before saving.');
}
await Promise.all(saves);
var formData = new FormData(slideForm);
if (event.submitter && event.submitter.name) {
@@ -1686,6 +2334,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
if (isCreateForm) {
try {
if (response.url) {
clearPendingUploadCleanupPaths();
window.location.replace(response.url);
return;
}
@@ -1711,6 +2360,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
showToast(savedMessage || 'Saved slide.', 'success');
slideForm.dataset.dirty = 'false';
clearPendingUploadCleanupPaths();
} catch (error) {
if (typeof showToast === 'function') {
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
@@ -1761,6 +2411,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
window.addEventListener('resize', requestPreviewRender);
window.addEventListener('resize', requestSidebarSync);
window.addEventListener('beforeunload', function () {
queueUploadCleanup(getPendingUploadCleanupPaths());
if (previewPopupWindow && !previewPopupWindow.closed) {
previewPopupWindow.close();
}
@@ -18,6 +18,8 @@
var currentRegionAspectRatio = NaN;
var currentRegionAspectRatioLabel = 'Region';
var listenersAttached = false;
var uploadMaxBytes = 100 * 1024 * 1024;
var uploadMaxLabel = '100 MB';
function getRegionId(input) {
var match = String(input && input.name || '').match(/^region_image_(\d+)$/);
@@ -249,6 +251,19 @@
input.files = dataTransfer.files;
}
function rejectOversizeFile(input) {
if (!input) {
return;
}
if (typeof window.showToast === 'function') {
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
} else {
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
}
input.value = '';
}
function finalizeCropFile(file) {
if (!currentInput) {
return;
@@ -357,6 +372,13 @@
return;
}
if (Number(file.size || 0) > uploadMaxBytes) {
event.stopImmediatePropagation();
event.stopPropagation();
rejectOversizeFile(input);
return;
}
if (!/^image\//i.test(file.type || '')) {
input.value = '';
return;
+4
View File
@@ -114,6 +114,10 @@
return false;
}
if (headerCell.getAttribute && headerCell.getAttribute('data-sortable') === 'false') {
return false;
}
if (/actions?|buttons?/i.test(getTableHeaderText(headerCell))) {
return false;
}
@@ -44,7 +44,7 @@
function getDefaultRegionSize(regionType, lockRatio) {
var ratio = parseLockRatio(lockRatio);
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
var locked = regionType === 'image' || regionType === 'video' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
if (ratio) {
if (ratio.ratio >= 1) {
@@ -312,7 +312,7 @@
}
function getRegionChipLabel(regionType) {
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
}
function populateRegionCard(card, region) {
@@ -334,7 +334,7 @@
nameInput.value = region.region_key || region.label || '';
}
if (fontFamilyInput) {
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
}
if (regionTypeInput) {
regionTypeInput.value = region.region_type || 'text';
+114 -1
View File
@@ -1,3 +1,6 @@
const fs = require('fs');
const path = require('path');
module.exports = function registerAdminContentRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
@@ -8,6 +11,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const collectUploadReferencesFromSlide = deps.collectUploadReferencesFromSlide;
const collectUploadReferencesFromTemplate = deps.collectUploadReferencesFromTemplate;
const collectUploadReferencesFromPayload = deps.collectUploadReferencesFromPayload;
const removeUnusedUploadFiles = deps.removeUnusedUploadFiles;
const syncPlaylistUploadsOnChange = deps.syncPlaylistUploadsOnChange;
const getAuditUserId = deps.getAuditUserId;
const redirectAfterSave = deps.redirectAfterSave;
@@ -18,14 +22,88 @@ module.exports = function registerAdminContentRoutes(app, deps) {
const getTemplateDeleteBlockMessage = deps.getTemplateDeleteBlockMessage;
const getCanvasSizeDeleteBlockMessage = deps.getCanvasSizeDeleteBlockMessage;
const requirePermission = deps.requirePermission;
const hasAnyPermission = deps.hasAnyPermission;
const { buildPagination } = require('../../lib/pagination');
const LIST_PAGE_SIZE = 10;
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function') {
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
}
function requireSlideUploadPermission(req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasAnyPermission(req.currentUser, ['slides.create', 'slides.update'])) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
}
function getUploadedFileMediaType(file) {
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
return 'video';
}
if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) !== -1) {
return 'image';
}
return null;
}
function getUploadedFileLimitBytes(file) {
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
}
function getUploadedFileLimitLabel(file) {
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
}
async function removeUploadedFile(file) {
if (!file || !file.filename || !deps.uploadDir) {
return;
}
const filePath = path.join(deps.uploadDir, file.filename);
try {
await fs.promises.unlink(filePath);
} catch (_error) {
// Ignore cleanup failures.
}
}
async function validateUploadedFiles(files) {
const list = Array.isArray(files) ? files.filter(Boolean) : [];
for (let i = 0; i < list.length; i += 1) {
const file = list[i];
const mediaType = getUploadedFileMediaType(file);
if (!mediaType) {
await removeUploadedFile(file);
const error = new Error('Unsupported upload type.');
error.statusCode = 400;
error.expose = true;
throw error;
}
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
await removeUploadedFile(file);
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.');
error.statusCode = 400;
error.expose = true;
throw error;
}
}
}
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
return Promise.resolve(null);
@@ -145,8 +223,42 @@ module.exports = function registerAdminContentRoutes(app, deps) {
}
});
app.post('/slides/uploads', requireSlideUploadPermission, upload.single('file'), async function (req, res, next) {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file was uploaded.' });
}
await validateUploadedFiles([req.file]);
res.json({
path: '/media/uploads/' + req.file.filename,
filename: req.file.filename,
originalName: req.file.originalname || ''
});
} catch (error) {
next(error);
}
});
app.post('/slides/uploads/cleanup', requireSlideUploadPermission, async function (req, res, next) {
try {
const uploadPaths = Array.isArray(req.body && req.body.uploadPaths)
? req.body.uploadPaths
: Array.isArray(req.body && req.body.paths)
? req.body.paths
: [];
await removeUnusedUploadFiles(pool, deps.uploadDir, uploadPaths);
res.json({ ok: true });
} catch (error) {
next(error);
}
});
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const payload = await common.buildSlidePayload(pool, req, null);
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
return res.status(400).send('A slide with that title already exists.');
@@ -177,6 +289,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
try {
await validateUploadedFiles(req.files || []);
const slide = await common.fetchSlideById(pool, Number(req.params.id));
if (!slide) {
return res.status(404).send('Slide not found');
+14 -5
View File
@@ -116,6 +116,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
@@ -126,6 +127,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (durations.length && durations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
return res.status(400).send('Playlist slide data is invalid.');
}
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
return res.status(400).send('Playlist schedule data is invalid.');
}
@@ -143,7 +147,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
seenSlideIds.add(slideId);
const durationRaw = Number(durations[i]);
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10;
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
let scheduleStartDatetime = null;
@@ -188,6 +193,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
slideId,
position: i,
durationSeconds,
useVideoDuration,
scheduleMode,
scheduleStartDatetime,
scheduleEndDatetime,
@@ -227,12 +233,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
for (let i = 0; i < normalizedSlides.length; i += 1) {
const item = normalizedSlides[i];
await connection.query(
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
[
playlist.id,
item.slideId,
item.position,
item.durationSeconds,
item.useVideoDuration,
item.scheduleMode,
item.scheduleStartDatetime,
item.scheduleEndDatetime,
@@ -303,11 +310,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
return res.status(400).send('The slide canvas size must match the existing playlist items.');
}
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
const nextPosition = Number(positionRows[0].max_position) + 1;
const actorId = getAuditUserId(req);
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
await broadcastDashboardState();
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
@@ -323,7 +331,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
return res.status(404).send('Playlist not found');
}
const playlistSlideId = Number(req.params.playlistSlideId);
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
const durationValue = Number(req.body.duration_seconds || 10);
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
const actorId = getAuditUserId(req);
const [result] = await pool.query(
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
+11 -1
View File
@@ -369,7 +369,17 @@ module.exports = function registerAdminRbacRoutes(app, deps) {
return res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('One or more selected permissions are invalid.'));
}
await rbacData.syncRolePermissions(pool, roleId, normalizedPermissionKeys);
const connection = await pool.getConnection();
try {
await connection.beginTransaction();
await rbacData.syncRolePermissions(connection, roleId, normalizedPermissionKeys);
await connection.commit();
} catch (error) {
await connection.rollback();
throw error;
} finally {
connection.release();
}
res.redirect('/rbac/' + roleId + '/edit?message=' + encodeURIComponent('Permissions updated.'));
} catch (error) {
next(error);
+2 -1
View File
@@ -27,7 +27,8 @@ module.exports = {
renderCanvasSizesPage: require('./canvas-sizes/list'),
renderCanvasSizeFormPage: require('./canvas-sizes/add'),
renderCanvasSizeEditPage: require('./canvas-sizes/edit'),
renderBackgroundTasksPage: require('./settings/background-tasks-page'),
renderBackgroundTasksPage: require('./settings/background-tasks-page').renderBackgroundTasksPage,
renderBackgroundTasksScheduledPage: require('./settings/background-tasks-page').renderBackgroundTasksScheduledPage,
renderErrorPage: require('./error'),
renderRbacPage: require('./rbac/list'),
renderRbacAddPage: require('./rbac/add'),
@@ -1,8 +1,10 @@
const { renderView } = require('../../view');
const { hasAnyPermission } = require('../../../rbac');
const { buildPaginationPages } = require('../../lib/pagination');
const TASKS_PER_PAGE = 10;
const TASK_STATUSES = new Set(['queued', 'running', 'failed', 'completed', 'canceled']);
const QUEUE_PAGE_URL = '/settings/background-tasks';
const SCHEDULED_PAGE_URL = '/settings/background-tasks/scheduled';
function formatIntervalLabel(intervalMs) {
const value = Math.max(1, Number(intervalMs) || 0);
@@ -22,11 +24,6 @@ function parsePageNumber(value) {
return Math.max(1, pageNumber);
}
function normalizeStatusFilter(value) {
const status = String(value || '').trim().toLowerCase();
return TASK_STATUSES.has(status) ? status : '';
}
function normalizeSourceFilter(sourceType, sourceId) {
const normalizedSourceType = String(sourceType || '').trim();
const normalizedSourceId = Math.floor(Number(sourceId) || 0);
@@ -61,16 +58,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState) {
const safeCurrentPage = Math.min(Math.max(1, currentPage), totalPages);
const startIndex = (safeCurrentPage - 1) * TASKS_PER_PAGE;
const endIndex = Math.min(totalItems, startIndex + TASKS_PER_PAGE);
const pages = [];
for (let pageNumber = 1; pageNumber <= totalPages; pageNumber += 1) {
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: pageNumber });
pages.push({
number: pageNumber,
active: pageNumber === safeCurrentPage,
url: buildQueryString(nextQuery)
});
}
const pages = buildPaginationPages(totalPages, safeCurrentPage, normalizedPageParam, queryState, 7);
const previousQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage - 1 });
const nextQuery = Object.assign({}, queryState, { [normalizedPageParam]: safeCurrentPage + 1 });
@@ -92,11 +80,7 @@ function buildPagination(totalItems, currentPage, pageParam, queryState) {
};
}
function taskMatchesFilters(task, statusFilter, sourceFilter) {
if (statusFilter && task.status !== statusFilter) {
return false;
}
function taskMatchesFilters(task, sourceFilter) {
if (!sourceFilter || !sourceFilter.sourceType || !sourceFilter.sourceId) {
return true;
}
@@ -106,12 +90,26 @@ function taskMatchesFilters(task, statusFilter, sourceFilter) {
}
function formatStatusLabel(status) {
const normalizedStatus = String(status || '').trim().toLowerCase();
if (!normalizedStatus) {
return 'All tasks';
const normalized = String(status || '').trim().toLowerCase();
if (!normalized) {
return '';
}
return normalizedStatus.charAt(0).toUpperCase() + normalizedStatus.slice(1);
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) {
@@ -133,18 +131,7 @@ function buildTaskSourceFilterUrl(queryState, task, sourceFilter) {
return buildQueryString(nextQuery);
}
function buildStatusFilterUrl(queryState, statusFilter) {
const nextQuery = Object.assign({}, queryState, {
page: 1,
status: statusFilter && statusFilter.status === statusFilter.targetStatus ? '' : statusFilter.targetStatus,
sourceType: statusFilter && statusFilter.sourceType ? statusFilter.sourceType : '',
sourceId: statusFilter && statusFilter.sourceId ? statusFilter.sourceId : ''
});
return buildQueryString(nextQuery);
}
module.exports = function renderBackgroundTasksPage(data, message, currentUser) {
function buildQueuePageViewModel(data, message, currentUser) {
const tasks = (data && data.tasks) || [];
const recurringTasks = (data && data.recurringTasks) || [];
const summary = (data && data.summary) || { counts: {}, total: 0, activeCount: 0 };
@@ -152,16 +139,14 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
const queryState = {
page: parsePageNumber(data && data.page),
recurringPage: parsePageNumber(data && data.recurringPage),
status: normalizeStatusFilter(data && data.status),
sourceType: '',
sourceId: ''
};
const sourceFilter = normalizeSourceFilter(data && data.sourceType, data && data.sourceId);
queryState.status = normalizeStatusFilter(data && data.status);
queryState.sourceType = sourceFilter.sourceType;
queryState.sourceId = sourceFilter.sourceId;
const filteredTasks = tasks.filter(function (task) {
return taskMatchesFilters(task, queryState.status, sourceFilter);
return taskMatchesFilters(task, sourceFilter);
});
const pagination = buildPagination(filteredTasks.length, queryState.page, 'page', queryState);
const recurringPagination = buildPagination(recurringTasks.length, queryState.recurringPage, 'recurringPage', queryState);
@@ -193,18 +178,8 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
variant: 'success'
}
].map(function (card) {
const isActive = queryState.status === card.status;
const statusFilter = {
status: queryState.status,
sourceType: queryState.sourceType,
sourceId: queryState.sourceId,
targetStatus: card.status
};
return Object.assign({}, card, {
active: isActive,
href: buildStatusFilterUrl(queryState, statusFilter),
ariaLabel: isActive ? `Clear ${card.label.toLowerCase()} filter` : `Filter tasks by ${card.label.toLowerCase()}`
active: false
});
});
@@ -216,24 +191,19 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
sourceIdLabel: task.metadata && task.metadata.sourceId ? Number(task.metadata.sourceId) : ''
});
});
const hasActiveFilters = Boolean(queryState.status || queryState.sourceType || queryState.sourceId);
const hasActiveFilters = Boolean(queryState.sourceType || queryState.sourceId);
return renderView('settings/background-tasks/index', {
return renderView('settings/tasks-background/index', {
title: 'Background tasks',
active: 'background-tasks',
message: message,
currentUser: currentUser || null,
scripts: ['js/settings/background-tasks.js'],
stateVersion: data && data.stateVersion ? String(data.stateVersion) : '',
backgroundTasksMenuOpen: true,
tasks: visibleTasksWithSources,
recurringTasks: visibleRecurringTasks.map(function (task) {
return Object.assign({}, task, {
intervalLabel: formatIntervalLabel(task.intervalMs)
});
}),
summary: summary,
statusCards: statusCards,
activeStatusLabel: formatStatusLabel(queryState.status),
activeSourceLabel: queryState.sourceType && queryState.sourceId ? `${queryState.sourceType} #${queryState.sourceId}` : '',
clearFiltersUrl: buildQueryString({ recurringPage: queryState.recurringPage }),
hasActiveFilters: hasActiveFilters,
@@ -244,4 +214,38 @@ module.exports = function renderBackgroundTasksPage(data, message, currentUser)
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);
}
};
+57 -1
View File
@@ -17,6 +17,23 @@ function requireSettingsAccess() {
};
}
function requireScheduledSettingsAccess() {
return function (req, res, next) {
if (!req.currentUser) {
return res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
if (hasAnyPermission(req.currentUser, ['scheduled-tasks.read', 'scheduled-tasks.allow'])) {
return next();
}
const error = new Error('You do not have permission to access this area.');
error.statusCode = 403;
error.expose = true;
next(error);
};
}
module.exports = function registerAdminSettingsRoutes(app, deps) {
const pages = deps.pages;
const backgroundTaskQueue = deps.backgroundTaskQueue;
@@ -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));
});
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 recurringTasks = backgroundTaskQueue.listRecurringTasks();
const summary = backgroundTaskQueue.getSummary();
@@ -96,4 +134,22 @@ module.exports = function registerAdminSettingsRoutes(app, deps) {
const retryTask = backgroundTaskQueue.retryTask(Number(req.params.id));
res.redirect('/settings/background-tasks?message=' + encodeURIComponent(retryTask ? 'Task requeued.' : 'Unable to retry that task.'));
});
app.post('/settings/background-tasks/recurring/:key/run', function (req, res, next) {
if (!req.currentUser) {
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.'));
});
};
+92 -2
View File
@@ -61,6 +61,86 @@ function getCanvasSignature(width, height) {
return normalizedWidth + 'x' + normalizedHeight;
}
function hasVideoRegion(contentJson) {
if (!contentJson) {
return false;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
const region = parsed[key];
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
}));
} catch (_error) {
return false;
}
}
function getVideoSourcePath(slide) {
if (!slide) {
return '';
}
const directMediaPath = String(slide.media_path || '').trim();
if (String(slide.media_type || '').trim().toLowerCase() === 'video' && directMediaPath) {
return directMediaPath;
}
const contentJson = slide.content_json;
if (!contentJson) {
return directMediaPath;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return directMediaPath;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
});
return videoRegion ? String(videoRegion.value || '').trim() : directMediaPath;
} catch (_error) {
return directMediaPath;
}
}
function getVideoDurationSeconds(slide) {
if (!slide) {
return null;
}
const contentJson = slide.content_json;
if (!contentJson) {
return null;
}
try {
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
if (!parsed || typeof parsed !== 'object') {
return null;
}
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
});
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
return Number.isFinite(duration) && duration > 0 ? duration : null;
} catch (_error) {
return null;
}
}
function getVideoDurationValue(slide) {
const storedDuration = Number(slide && slide.duration_seconds || 0);
const videoDuration = getVideoDurationSeconds(slide);
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
}
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
const playlistSlides = (data.playlistSlides || [])
.filter((item) => item.playlist_id === playlist.id)
@@ -75,6 +155,11 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
showVideoDurationButton: String(item.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(item.content_json),
videoSourcePath: getVideoSourcePath(item),
videoDurationSeconds: getVideoDurationSeconds(item),
useVideoDuration: Boolean(item.use_video_duration),
durationSeconds: getVideoDurationValue(item),
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
@@ -91,7 +176,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id)
isAssigned: assignedSlideIds.has(slide.id),
showVideoDurationButton: String(slide.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(slide.content_json),
videoSourcePath: getVideoSourcePath(slide),
videoDurationSeconds: getVideoDurationSeconds(slide),
useVideoDuration: Boolean(slide.use_video_duration),
durationSeconds: getVideoDurationValue(slide)
}));
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
@@ -111,6 +201,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/lib/modal.js', 'js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
scripts: ['js/lib/modal.js?v=20260726.7', 'js/vendor/sortable.min.js?v=20260726.7', 'js/playlists/playlist-schedule.js?v=20260726.7']
});
};
@@ -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. Click a status box or source name to filter the results.</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">
Showing results for {{#if activeStatusLabel}}{{activeStatusLabel}}{{else}}all tasks{{/if}}{{#if activeSourceLabel}} 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">
<a href="{{href}}" class="d-block h-100 text-decoration-none text-body" data-background-tasks-nav="status" aria-label="{{ariaLabel}}"{{#if active}} aria-current="page"{{/if}}>
<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>
</a>
</div>
{{/each}}
</div>
<div id="background-tasks-task-card" class="card card-outline card-primary mb-4" data-pagination-card="tasks" data-table-search-container>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Task queue</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-task-tools">
<div class="input-group input-group-sm background-tasks-task-search" style="width: min(12rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search tasks" aria-label="Search tasks" data-table-search />
</div>
{{#if canManage}}
{{#if canClearFinished}}
<form method="post" action="/settings/background-tasks/clear-finished">
<button type="submit" class="btn btn-outline-secondary btn-sm" data-confirm-message="Clear finished background tasks?">Clear finished</button>
</form>
{{/if}}
{{/if}}
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th>Task</th>
<th>Category</th>
<th>Status</th>
<th>Queued</th>
<th>Started</th>
<th>Finished</th>
<th>Source</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{{#if tasks.length}}
{{#each tasks}}
<tr data-table-search-row>
<td data-label="Task">
<div class="fw-semibold">{{title}}</div>
{{#if key}}
<div class="text-muted small text-break">{{key}}</div>
{{/if}}
{{#if errorMessage}}
<div class="text-danger small mt-1 text-break">{{errorMessage}}</div>
{{/if}}
</td>
<td data-label="Category">{{category}}</td>
<td data-label="Status">
{{#if (eq status 'queued')}}
<span class="badge text-bg-secondary">Queued</span>
{{else if (eq status 'running')}}
<span class="badge text-bg-primary">Running</span>
{{else if (eq status 'completed')}}
<span class="badge text-bg-success">Completed</span>
{{else if (eq status 'failed')}}
<span class="badge text-bg-danger">Failed</span>
{{else if (eq status 'canceled')}}
<span class="badge text-bg-warning">Canceled</span>
{{else}}
<span class="badge text-bg-light text-dark">{{status}}</span>
{{/if}}
</td>
<td data-label="Queued">
{{#if createdAt}}
<time data-local-datetime datetime="{{createdAt}}">{{createdAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Started">
{{#if startedAt}}
<time data-local-datetime datetime="{{startedAt}}">{{startedAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Finished">
{{#if finishedAt}}
<time data-local-datetime datetime="{{finishedAt}}">{{finishedAt}}</time>
{{else}}
-
{{/if}}
</td>
<td data-label="Source">
{{#if metadata.sourceName}}
<div>{{metadata.sourceName}}</div>
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
<td data-label="Actions">
<div class="d-flex flex-wrap gap-2">
{{#if canManage}}
{{#if (eq status 'queued')}}
<form method="post" action="/settings/background-tasks/{{id}}/cancel">
<button type="submit" class="btn btn-outline-danger btn-sm" data-confirm-message="Cancel this queued task?">Cancel</button>
</form>
{{/if}}
{{#if (eq status 'failed')}}
<form method="post" action="/settings/background-tasks/{{id}}/retry">
<button type="submit" class="btn btn-outline-primary btn-sm">Retry</button>
</form>
{{/if}}
{{/if}}
</div>
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default>
<td colspan="8" class="empty">No background tasks are queued right now.</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
{{#if pagination.hasMultiplePages}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="text-muted small">
Showing {{pagination.startItem}}-{{pagination.endItem}} of {{pagination.totalItems}} tasks
</div>
<nav aria-label="Background task pages">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless pagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasPrevious}}/settings/background-tasks{{pagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li>
{{#each pagination.pages}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
</li>
{{/each}}
<li class="page-item {{#unless pagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if pagination.hasNext}}/settings/background-tasks{{pagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
</li>
</ul>
</nav>
</div>
{{/if}}
</div>
<div id="background-tasks-recurring-card" class="card card-outline card-secondary" data-pagination-card="recurring" data-table-search-container>
<div class="card-header d-flex align-items-center">
<h3 class="card-title mb-0">Scheduled refreshes</h3>
<div class="ms-auto d-flex flex-nowrap align-items-center gap-2 background-tasks-recurring-tools">
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
<span class="input-group-text" aria-hidden="true"><i class="bi bi-search"></i></span>
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
</div>
</div>
</div>
<div class="card-body table-responsive p-0">
<table class="table table-striped w-100 mb-0 align-middle" data-table-searchable>
<thead>
<tr>
<th>Task</th>
<th>Interval</th>
<th>Next run</th>
<th>Last run</th>
<th>Last result</th>
<th>Source</th>
</tr>
</thead>
<tbody>
{{#if recurringTasks.length}}
{{#each recurringTasks}}
<tr data-table-search-row>
<td data-label="Task">
<div class="fw-semibold">{{title}}</div>
<div class="text-muted small text-break">{{key}}</div>
</td>
<td data-label="Interval">{{intervalLabel}}</td>
<td data-label="Next run">
{{#if nextRunAt}}
<time data-local-datetime datetime="{{nextRunAt}}">{{nextRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last run">
{{#if lastRunAt}}
<time data-local-datetime datetime="{{lastRunAt}}">{{lastRunAt}}</time>
{{else}}-{{/if}}
</td>
<td data-label="Last result">
{{#if lastStatus}}
<span class="badge {{#if (eq lastStatus 'failed')}}text-bg-danger{{else if (eq lastStatus 'completed')}}text-bg-success{{else}}text-bg-secondary{{/if}}">{{lastStatus}}</span>
{{#if lastError}}<div class="text-danger small mt-1 text-break">{{lastError}}</div>{{/if}}
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
<td data-label="Source">
{{#if metadata.sourceName}}
<div>{{metadata.sourceName}}</div>
<div class="text-muted small">{{metadata.sourceType}} #{{metadata.sourceId}}</div>
{{else}}
<span class="empty">-</span>
{{/if}}
</td>
</tr>
{{/each}}
{{else}}
<tr data-table-search-empty-default>
<td colspan="6" class="empty">No scheduled refreshes are registered yet.</td>
</tr>
{{/if}}
</tbody>
</table>
</div>
{{#if recurringPagination.hasMultiplePages}}
<div class="card-footer d-flex flex-wrap align-items-center justify-content-between gap-3">
<div class="text-muted small">
Showing {{recurringPagination.startItem}}-{{recurringPagination.endItem}} of {{recurringPagination.totalItems}} scheduled refreshes
</div>
<nav aria-label="Scheduled refresh pages">
<ul class="pagination pagination-sm mb-0">
<li class="page-item {{#unless recurringPagination.hasPrevious}}disabled{{/unless}}">
<a class="page-link" href="{{#if recurringPagination.hasPrevious}}/settings/background-tasks{{recurringPagination.previousUrl}}{{else}}#{{/if}}" aria-label="Previous page">Previous</a>
</li>
{{#each recurringPagination.pages}}
<li class="page-item {{#if active}}active{{/if}}">
<a class="page-link" href="/settings/background-tasks{{url}}">{{number}}</a>
</li>
{{/each}}
<li class="page-item {{#unless recurringPagination.hasNext}}disabled{{/unless}}">
<a class="page-link" href="{{#if recurringPagination.hasNext}}/settings/background-tasks{{recurringPagination.nextUrl}}{{else}}#{{/if}}" aria-label="Next page">Next</a>
</li>
</ul>
</nav>
</div>
{{/if}}
</div>
@@ -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">
<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>
+59 -59
View File
@@ -5,70 +5,70 @@
</div>
</div>
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card">
<div class="card-header">
<h3 class="card-title">User details</h3>
<form id="user-form" method="post" action="/users">
<div class="row g-4">
<div class="col-12 col-xl-4">
<div class="card card-outline card-primary admin-form-card">
<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>
<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="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>
</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>
{{#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>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
</div>
{{/each}}
{{else}}
<div class="col-12">
<div class="alert alert-warning mb-0">Create at least one role before adding users.</div>
</div>
{{/if}}
{{/if}}
</div>
<div class="form-text mt-2">Select at least one role so the new user can sign in with access.</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>
</form>
+27 -6
View File
@@ -213,7 +213,7 @@
</li>
{{/if}}
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read' 'users.read' 'rbac.read')}}
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read' 'users.read' 'rbac.read')}}
<li class="nav-header">SETTINGS</li>
{{#if (hasPermission currentUser 'users.read')}}
<li class="nav-item">
@@ -231,12 +231,33 @@
</a>
</li>
{{/if}}
{{#if (anyPermission currentUser 'background-tasks.read')}}
<li class="nav-item">
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/background-tasks">
<i class="nav-icon bi bi-list-task"></i>
<p>Background tasks</p>
{{#if (anyPermission currentUser 'background-tasks.read' 'scheduled-tasks.read')}}
<li class="nav-item has-treeview{{#if backgroundTasksMenuOpen}} menu-open{{/if}}">
<a class="nav-link{{#if backgroundTasksMenuOpen}} active{{/if}}" href="#">
<i class="nav-icon bi bi-pen"></i>
<p>
Tasks
<i class="nav-arrow bi bi-chevron-right"></i>
</p>
</a>
{{#if (anyPermission currentUser 'background-tasks.read')}}
<ul class="nav nav-treeview">
<li class="nav-item">
<a class="nav-link {{#if (eq active 'background-tasks')}}active{{/if}}" href="/settings/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>
{{/if}}
{{/if}}
+22 -8
View File
@@ -83,7 +83,7 @@
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}">
{{#if playlistSlides.length}}
{{#each playlistSlides}}
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}">
<tr data-playlist-slide-row data-row-key="existing-{{id}}" data-slide-id="{{slide_id}}" data-canvas-signature="{{canvasSignature}}" data-media-type="{{media_type}}" data-media-path="{{media_path}}" data-video-source-path="{{videoSourcePath}}" data-video-duration-seconds="{{videoDurationSeconds}}" data-use-video-duration="{{#if useVideoDuration}}true{{else}}false{{/if}}">
<td class="playlist-order-cell" data-label="Order">
<div class="playlist-order-cell-inner">
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder">
@@ -100,7 +100,7 @@
</td>
<td data-label="Slide">
<div class="playlist-slide-cell">
<div class="playlist-slide-thumb" aria-hidden="true">
<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}}
@@ -123,7 +123,16 @@
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
</td>
<td data-label="Duration">
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" />
<div class="playlist-duration-field">
<input name="duration_seconds[]" type="text" inputmode="decimal" value="{{durationSeconds}}" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" {{#if useVideoDuration}}disabled{{/if}} />
<input type="hidden" name="use_video_duration[]" value="{{#if useVideoDuration}}1{{else}}0{{/if}}" form="playlist-edit-form" />
{{#if useVideoDuration}}
<input type="hidden" name="duration_seconds[]" value="{{durationSeconds}}" form="playlist-edit-form" data-video-duration-mirror />
{{/if}}
{{#if showVideoDurationButton}}
<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration{{#if useVideoDuration}} active{{/if}}" data-use-video-duration aria-pressed="{{#if useVideoDuration}}true{{else}}false{{/if}}">{{#if useVideoDuration}}Use Video Duration{{else}}Use video duration{{/if}}</button>
{{/if}}
</div>
</td>
<td data-label="Actions">
<div class="actions playlist-item-actions">
@@ -153,11 +162,16 @@
<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">
<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 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>
+5 -170
View File
@@ -48,7 +48,7 @@
</div>
<aside class="card card-outline card-secondary admin-form-card slide-preview-card">
<div class="card-header d-flex align-items-center justify-content-between gap-2">
<div class="card-header d-flex align-items-center justify-content-between">
<div class="slide-preview-header">
<h3>Preview</h3>
</div>
@@ -57,7 +57,7 @@
<div class="card-body slide-preview-scroll">
<div class="slide-preview-stage" id="slide-preview-stage">
<div class="slide-preview-canvas" id="slide-preview-canvas">
<img id="slide-preview-background" class="slide-preview-background" alt="" style="display:none;" />
<img id="slide-preview-background" class="slide-preview-background d-none" alt="" />
<div class="slide-preview-empty" id="slide-preview-empty">Select a template to preview the slide.</div>
<div class="slide-preview-overlay" id="slide-preview-overlay"></div>
</div>
@@ -76,7 +76,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body d-grid gap-3">
<div class="slide-image-cropper-frame" style="width:100%;height:560px;max-height:70vh;overflow:hidden;">
<div class="slide-image-cropper-frame">
<img id="slide-image-cropper-image" alt="Selected image to crop" />
</div>
<div class="btn-toolbar flex-wrap gap-2 slide-image-cropper-toolbar" role="toolbar" aria-label="Image editing controls">
@@ -107,177 +107,12 @@
</div>
{{/modal-shell}}
<style>
.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: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;
}
</style>
{{#if isEdit}}
<form id="delete-slide-form" method="post" action="/slides/{{slide.id}}/delete" data-confirm-message="Delete this slide?"></form>
{{/if}}
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
<script type="module" src="/assets/js/slides/slide-form.js?v=20260725.7"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260725.4"></script>
<script type="module" src="/assets/js/slides/slide-form.js?v=20260726.5"></script>
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.5"></script>
+2 -2
View File
@@ -22,7 +22,7 @@
<table class="table table-striped w-100 mb-0" data-table-searchable>
<thead>
<tr>
<th>Thumbnail</th>
<th data-sortable="false">Thumbnail</th>
<th>Title</th>
<th>Template</th>
<th>Actions</th>
@@ -33,7 +33,7 @@
{{#each slides}}
<tr data-table-search-row data-search-text="{{title}} {{template_name}}">
<td data-label="Thumbnail">
<div class="playlist-slide-thumb" aria-hidden="true">
<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}}
+1
View File
@@ -116,6 +116,7 @@
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>
+1
View File
@@ -123,6 +123,7 @@
<div class="d-grid gap-2">
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="html">HTML</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="image">Image</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="video">Video</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="api">API</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rtmp">RTMP</button>
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</button>