Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4afaeaafb1 | ||
|
|
ef9cdd2986 | ||
|
|
2176fb9042 | ||
|
|
6cb45d7839 | ||
|
|
eb1f33d82f |
@@ -6,6 +6,32 @@ All notable changes to this project will be documented in this file.
|
|||||||
|
|
||||||
- No unreleased changes recorded yet.
|
- No unreleased changes recorded yet.
|
||||||
|
|
||||||
|
## 1.5.12 - 2026-07-26
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- Shared web UI helpers are now loaded once in the admin shell and reused across the dashboard, admin, and template editor.
|
||||||
|
- Template lists now show slide counts, and the template editor tracks which regions are already in use.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Template editing now blocks removal of regions that are still referenced by slides.
|
||||||
|
|
||||||
|
## 1.5.11 - 2026-07-26
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- Video regions are now supported end to end, including player rendering, admin editing, playlist scheduling, and player sync.
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
|
||||||
|
- The slide, template, and playlist flows were updated so video content can be created, configured, and scheduled alongside existing region types.
|
||||||
|
- Player rendering and related media handling now recognize the new video region implementation.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Region and upload handling were adjusted so video media syncs cleanly through the player and web admin paths.
|
||||||
|
|
||||||
## 1.5.10 - 2026-07-26
|
## 1.5.10 - 2026-07-26
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.10",
|
"version": "1.5.12",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.10",
|
"version": "1.5.12",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@sparticuz/chromium": "^137.0.0",
|
"@sparticuz/chromium": "^137.0.0",
|
||||||
"bootstrap-icons": "1.11.3",
|
"bootstrap-icons": "1.11.3",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "1.5.10",
|
"version": "1.5.12",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"repository": {
|
"repository": {
|
||||||
|
|||||||
+3
-2
@@ -25,7 +25,7 @@ async function fetchAdminData(pool) {
|
|||||||
ORDER BY s.id DESC
|
ORDER BY s.id DESC
|
||||||
`);
|
`);
|
||||||
const [playlistSlides] = await pool.query(`
|
const [playlistSlides] = await pool.query(`
|
||||||
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, 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
|
FROM playlist_slides ps
|
||||||
JOIN slides sl ON sl.id = ps.slide_id
|
JOIN slides sl ON sl.id = ps.slide_id
|
||||||
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
LEFT JOIN slide_templates st ON st.id = sl.template_id
|
||||||
@@ -68,7 +68,8 @@ async function fetchTemplatesPage(pool, page, pageSize) {
|
|||||||
const paged = await fetchPagedRows(pool, {
|
const paged = await fetchPagedRows(pool, {
|
||||||
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
selectSql: `SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
|
||||||
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height,
|
||||||
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count
|
(SELECT COUNT(*) FROM slide_template_regions str WHERE str.template_id = st.id) AS region_count,
|
||||||
|
(SELECT COUNT(*) FROM slides s WHERE s.template_id = st.id) AS slide_count
|
||||||
FROM slide_templates st
|
FROM slide_templates st
|
||||||
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY st.id DESC`,
|
ORDER BY st.id DESC`,
|
||||||
|
|||||||
@@ -92,6 +92,18 @@ function buildTemplateContent(template, body, filesByField, existingContent) {
|
|||||||
type: 'image',
|
type: 'image',
|
||||||
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
|
||||||
};
|
};
|
||||||
|
} else if (region.region_type === 'video') {
|
||||||
|
const uploaded = filesByField[`region_video_${region.id}`];
|
||||||
|
const existing = body[`existing_region_video_${region.id}`];
|
||||||
|
const durationValue = body[`existing_region_video_duration_${region.id}`];
|
||||||
|
const existingDuration = existingContent && existingContent[region.region_key] ? Number(existingContent[region.region_key].duration_seconds || 0) : 0;
|
||||||
|
const parsedDuration = Number(durationValue || existingDuration || 0);
|
||||||
|
const normalizedDuration = Math.round(parsedDuration * 1000) / 1000;
|
||||||
|
content[region.region_key] = {
|
||||||
|
type: 'video',
|
||||||
|
value: uploaded ? `/media/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : ''),
|
||||||
|
duration_seconds: Number.isFinite(normalizedDuration) && normalizedDuration > 0 ? normalizedDuration : null
|
||||||
|
};
|
||||||
} else if (region.region_type === 'webpage') {
|
} else if (region.region_type === 'webpage') {
|
||||||
const submitted = body[`region_webpage_${region.id}`];
|
const submitted = body[`region_webpage_${region.id}`];
|
||||||
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
const { parseJsonSafe, readFormArray } = require('./utils');
|
const { parseJsonSafe, readFormArray } = require('./utils');
|
||||||
|
|
||||||
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'video', 'webpage', 'html', 'rtmp', 'rss', 'api'];
|
||||||
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
const FONT_FAMILY_REGION_TYPES = ['text', 'html', 'rss', 'api'];
|
||||||
|
|
||||||
function sanitizeBackgroundColor(value) {
|
function sanitizeBackgroundColor(value) {
|
||||||
|
|||||||
+2
-1
@@ -117,7 +117,8 @@ async function ensureSchema(pool, options) {
|
|||||||
playlist_id INT NOT NULL,
|
playlist_id INT NOT NULL,
|
||||||
slide_id INT NOT NULL,
|
slide_id INT NOT NULL,
|
||||||
position INT NOT NULL DEFAULT 0,
|
position INT NOT NULL DEFAULT 0,
|
||||||
duration_seconds INT NOT NULL DEFAULT 10,
|
duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000,
|
||||||
|
use_video_duration TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
schedule_mode VARCHAR(20) NOT NULL DEFAULT 'always',
|
||||||
schedule_start_datetime DATETIME NULL,
|
schedule_start_datetime DATETIME NULL,
|
||||||
schedule_end_datetime DATETIME NULL,
|
schedule_end_datetime DATETIME NULL,
|
||||||
|
|||||||
+31
-13
@@ -605,20 +605,20 @@ const migrations = [
|
|||||||
{
|
{
|
||||||
key: 'schema-columns-current',
|
key: 'schema-columns-current',
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
comment: 'Add the current table columns and audit fields that define the released schema.',
|
comment: 'Backfill the released schema columns for older databases.',
|
||||||
order: 20,
|
order: 20,
|
||||||
up: async function (pool) {
|
up: async function (pool) {
|
||||||
// v1.4.6: keep the current canvas_sizes shape available in older databases.
|
// Current released schema: keep canvas_sizes audit fields available in older databases.
|
||||||
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'canvas_sizes', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'canvas_sizes');
|
await addAuditColumns(pool, 'canvas_sizes');
|
||||||
|
|
||||||
// v1.4.6: playlists gained fade_between_slides plus audit fields.
|
// Current released schema: ensure playlists carry the playback and audit fields.
|
||||||
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
await addColumnIfMissing(pool, 'playlists', 'fade_between_slides', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
await addColumnIfMissing(pool, 'playlists', 'skip_unavailable_rtmp', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'playlists', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'playlists');
|
await addAuditColumns(pool, 'playlists');
|
||||||
|
|
||||||
// v1.4.6: slide_templates now store canvas sizing and background metadata.
|
// Current released schema: keep slide template canvas and background fields available.
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
await addColumnIfMissing(pool, 'slide_templates', 'canvas_size_id', 'INT NULL');
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
await addColumnIfMissing(pool, 'slide_templates', 'background_image_path', 'VARCHAR(512) NULL');
|
||||||
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
await addColumnIfMissing(pool, 'slide_templates', 'background_color', 'VARCHAR(32) NULL');
|
||||||
@@ -626,13 +626,13 @@ const migrations = [
|
|||||||
await addAuditColumns(pool, 'slide_templates');
|
await addAuditColumns(pool, 'slide_templates');
|
||||||
await backfillLegacySlideTemplateCanvasSize(pool);
|
await backfillLegacySlideTemplateCanvasSize(pool);
|
||||||
|
|
||||||
// v1.4.6: slide_template_regions gained font family and audit fields.
|
// Current released schema: keep slide template region typography and audit fields available.
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
|
await addColumnIfMissing(pool, 'slide_template_regions', 'font_family', 'VARCHAR(100) NULL');
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
|
await addColumnIfMissing(pool, 'slide_template_regions', 'lock_ratio', 'VARCHAR(20) NULL');
|
||||||
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'slide_template_regions', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'slide_template_regions');
|
await addAuditColumns(pool, 'slide_template_regions');
|
||||||
|
|
||||||
// v1.4.6: slides gained structured content and media fields.
|
// Current released schema: keep structured slide content and media fields available.
|
||||||
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
|
await addColumnIfMissing(pool, 'slides', 'body', 'TEXT NULL');
|
||||||
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
await addColumnIfMissing(pool, 'slides', 'template_id', 'INT NULL');
|
||||||
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
await addColumnIfMissing(pool, 'slides', 'content_json', 'JSON NULL');
|
||||||
@@ -642,8 +642,8 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'slides');
|
await addAuditColumns(pool, 'slides');
|
||||||
|
|
||||||
// v1.4.6: playlist_slides gained scheduling fields and audit fields.
|
// Current released schema: keep playlist slide duration, schedule, and audit fields available.
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'INT NOT NULL DEFAULT 10');
|
await addColumnIfMissing(pool, 'playlist_slides', 'duration_seconds', 'DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_mode', "VARCHAR(20) NOT NULL DEFAULT 'always'");
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_start_datetime', 'DATETIME NULL');
|
||||||
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
await addColumnIfMissing(pool, 'playlist_slides', 'schedule_end_datetime', 'DATETIME NULL');
|
||||||
@@ -653,12 +653,12 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'playlist_slides', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'playlist_slides');
|
await addAuditColumns(pool, 'playlist_slides');
|
||||||
|
|
||||||
// v1.4.6: screens gained an optional playlist binding and audit fields.
|
// Current released schema: keep screen playlist bindings and audit fields available.
|
||||||
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
|
await addColumnIfMissing(pool, 'screens', 'playlist_id', 'INT NULL');
|
||||||
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'screens', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'screens');
|
await addAuditColumns(pool, 'screens');
|
||||||
|
|
||||||
// v1.4.6: RSS feeds now use a neutral interval value plus unit.
|
// Current released schema: keep RSS feed interval and audit fields available.
|
||||||
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
|
await addColumnIfMissing(pool, 'rss_feeds', 'name', 'VARCHAR(255) NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
|
await addColumnIfMissing(pool, 'rss_feeds', 'feed_url', 'VARCHAR(1024) NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
await addColumnIfMissing(pool, 'rss_feeds', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
@@ -667,14 +667,14 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'rss_feeds', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'rss_feeds');
|
await addAuditColumns(pool, 'rss_feeds');
|
||||||
|
|
||||||
// v1.4.6: rss_feed_items stores normalized JSON snapshots.
|
// Current released schema: keep normalized RSS feed item snapshots available.
|
||||||
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
|
await addColumnIfMissing(pool, 'rss_feed_items', 'rss_feed_id', 'INT NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
|
await addColumnIfMissing(pool, 'rss_feed_items', 'position', 'INT NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
|
await addColumnIfMissing(pool, 'rss_feed_items', 'item_json', 'MEDIUMTEXT NULL');
|
||||||
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'rss_feed_items', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await backfillLegacyRssFeedItemJson(pool);
|
await backfillLegacyRssFeedItemJson(pool);
|
||||||
|
|
||||||
// v1.4.6: API sources now track their latest response snapshot.
|
// Current released schema: keep API source polling and snapshot fields available.
|
||||||
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
|
await addColumnIfMissing(pool, 'api_sources', 'name', 'VARCHAR(255) NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
|
await addColumnIfMissing(pool, 'api_sources', 'api_url', 'VARCHAR(1024) NOT NULL');
|
||||||
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
await addColumnIfMissing(pool, 'api_sources', 'update_interval_value', 'INT NOT NULL DEFAULT 60');
|
||||||
@@ -687,7 +687,7 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'api_sources', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
await addAuditColumns(pool, 'api_sources');
|
await addAuditColumns(pool, 'api_sources');
|
||||||
|
|
||||||
// v1.4.6: onboarding devices, users, roles, permissions, and link tables now carry audit fields.
|
// Current released schema: keep onboarding device and RBAC audit fields available.
|
||||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
|
await addColumnIfMissing(pool, 'player_onboarding_devices', 'client_name', 'VARCHAR(255) NULL');
|
||||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
|
await addColumnIfMissing(pool, 'player_onboarding_devices', 'screen_id', 'INT NULL');
|
||||||
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
await addColumnIfMissing(pool, 'player_onboarding_devices', 'modified_at', 'TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP');
|
||||||
@@ -741,6 +741,24 @@ const migrations = [
|
|||||||
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
await addColumnIfMissing(pool, 'slides', 'thumbnail_path', 'VARCHAR(512) NULL');
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: 'playlist-slide-use-video-duration-flag',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Add the playlist slide flag that follows the current video duration.',
|
||||||
|
order: 23,
|
||||||
|
up: async function (pool) {
|
||||||
|
await addColumnIfMissing(pool, 'playlist_slides', 'use_video_duration', 'TINYINT(1) NOT NULL DEFAULT 0');
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'playlist-slide-duration-decimals',
|
||||||
|
version: appVersion,
|
||||||
|
comment: 'Widen playlist slide durations to preserve fractional seconds.',
|
||||||
|
order: 24,
|
||||||
|
up: async function (pool) {
|
||||||
|
await pool.query('ALTER TABLE playlist_slides MODIFY duration_seconds DECIMAL(10,3) NOT NULL DEFAULT 10.000');
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: 'media-path-prefix-rename',
|
key: 'media-path-prefix-rename',
|
||||||
version: appVersion,
|
version: appVersion,
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
let slideMarkupCache = Object.create(null);
|
let slideMarkupCache = Object.create(null);
|
||||||
let templateLayoutCache = Object.create(null);
|
let templateLayoutCache = Object.create(null);
|
||||||
let templateRenderPlanCache = Object.create(null);
|
let templateRenderPlanCache = Object.create(null);
|
||||||
|
let videoRegionRenderVersion = 0;
|
||||||
let renderCacheViewportKey = '';
|
let renderCacheViewportKey = '';
|
||||||
let index = 0;
|
let index = 0;
|
||||||
let timer = null;
|
let timer = null;
|
||||||
|
|||||||
+40
-5
@@ -73,11 +73,11 @@ function createPlayerPlaylistService(options) {
|
|||||||
return payloadWithoutPlaylist;
|
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 playlist = playlistRows[0] || null;
|
||||||
const [slideRows] = await pool.query(`
|
const [slideRows] = await pool.query(`
|
||||||
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
SELECT sl.id, sl.title, sl.body, sl.template_id, sl.content_json, sl.media_path, sl.media_type, sl.created_at, sl.modified_at,
|
||||||
ps.position, ps.duration_seconds AS duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
ps.position, ps.duration_seconds AS duration_seconds, ps.use_video_duration, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json,
|
||||||
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height, cs.width AS canvas_width, cs.height AS canvas_height
|
||||||
FROM playlist_slides ps
|
FROM playlist_slides ps
|
||||||
JOIN slides sl ON sl.id = ps.slide_id
|
JOIN slides sl ON sl.id = ps.slide_id
|
||||||
@@ -108,12 +108,46 @@ function createPlayerPlaylistService(options) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getVideoRegionDurationSeconds(contentJson) {
|
||||||
|
if (!contentJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = common.parseJsonSafe(contentJson) || {};
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoRegion = Object.keys(parsed).map(function (key) { return parsed[key]; }).find(function (region) {
|
||||||
|
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||||
|
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const slides = slideRows.map(function (slide) {
|
const slides = slideRows.map(function (slide) {
|
||||||
|
const storedDuration = Number(slide.duration_seconds || 0);
|
||||||
|
const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null;
|
||||||
|
const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||||
|
const content = common.parseJsonSafe(slide.content_json) || {};
|
||||||
|
Object.keys(content).forEach(function (key) {
|
||||||
|
const region = content[key];
|
||||||
|
if (region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video') {
|
||||||
|
region.cache_bust = videoCacheBust;
|
||||||
|
}
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
id: slide.id,
|
id: slide.id,
|
||||||
title: slide.title,
|
title: slide.title,
|
||||||
body: slide.body,
|
body: slide.body,
|
||||||
duration_seconds: slide.duration_seconds,
|
modified_at: slide.modified_at,
|
||||||
|
duration_seconds: videoDuration || storedDuration,
|
||||||
|
use_video_duration: Boolean(slide.use_video_duration),
|
||||||
schedule_mode: slide.schedule_mode,
|
schedule_mode: slide.schedule_mode,
|
||||||
schedule_start_datetime: slide.schedule_start_datetime,
|
schedule_start_datetime: slide.schedule_start_datetime,
|
||||||
schedule_end_datetime: slide.schedule_end_datetime,
|
schedule_end_datetime: slide.schedule_end_datetime,
|
||||||
@@ -125,7 +159,7 @@ function createPlayerPlaylistService(options) {
|
|||||||
kind: common.mediaKind(slide.media_path),
|
kind: common.mediaKind(slide.media_path),
|
||||||
template_id: slide.template_id,
|
template_id: slide.template_id,
|
||||||
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
template: slide.template_id ? templatesById[slide.template_id] || null : null,
|
||||||
content: common.parseJsonSafe(slide.content_json) || {}
|
content: content
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -193,6 +227,7 @@ function createPlayerPlaylistService(options) {
|
|||||||
updatePlaylistRevisionHash(hash, slide.modified_at);
|
updatePlaylistRevisionHash(hash, slide.modified_at);
|
||||||
updatePlaylistRevisionHash(hash, slide.position);
|
updatePlaylistRevisionHash(hash, slide.position);
|
||||||
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
updatePlaylistRevisionHash(hash, slide.duration_seconds);
|
||||||
|
updatePlaylistRevisionHash(hash, slide.use_video_duration);
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
updatePlaylistRevisionHash(hash, slide.schedule_mode);
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
updatePlaylistRevisionHash(hash, slide.schedule_start_datetime);
|
||||||
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
updatePlaylistRevisionHash(hash, slide.schedule_end_datetime);
|
||||||
|
|||||||
@@ -364,6 +364,19 @@ body.screen-blackout #app {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-region.video {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.template-region.video video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
pointer-events: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
.template-region.webpage iframe {
|
.template-region.webpage iframe {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
@@ -89,10 +89,10 @@ function scheduleSlideAdvance(delayMs) {
|
|||||||
}, holdDelayMs);
|
}, holdDelayMs);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add the fade time to a slide's hold duration so the configured duration remains visible.
|
// Add the fade time to a slide's hold duration unless the slide duration already accounts for it.
|
||||||
function getSlideHoldDelay(delayMs) {
|
function getSlideHoldDelay(delayMs, skipFadePadding) {
|
||||||
var holdDelayMs = Math.max(1, Number(delayMs || 0));
|
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.
|
// Cancel any pending fade-transition cleanup.
|
||||||
@@ -109,11 +109,48 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
if (typeof destroyRtmpRegions === 'function') {
|
if (typeof destroyRtmpRegions === 'function') {
|
||||||
destroyRtmpRegions(app);
|
destroyRtmpRegions(app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function initializeRenderedVideoPlayback(root) {
|
||||||
|
if (!root) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var videos = root.querySelectorAll('.template-region.video video');
|
||||||
|
Array.prototype.forEach.call(videos, function (video) {
|
||||||
|
if (!video) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.autoplay = true;
|
||||||
|
video.loop = true;
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
|
||||||
|
function startPlayback() {
|
||||||
|
var playPromise = video.play && video.play();
|
||||||
|
if (playPromise && typeof playPromise.catch === 'function') {
|
||||||
|
playPromise.catch(function () {
|
||||||
|
return null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (video.readyState >= 2) {
|
||||||
|
startPlayback();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
video.addEventListener('canplay', startPlayback, { once: true });
|
||||||
|
video.addEventListener('loadedmetadata', startPlayback, { once: true });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldFade) {
|
if (!shouldFade) {
|
||||||
app.innerHTML = markup;
|
app.innerHTML = markup;
|
||||||
if (typeof syncRtmpRegions === 'function') {
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
syncRtmpRegions(app);
|
syncRtmpRegions(app);
|
||||||
}
|
}
|
||||||
|
initializeRenderedVideoPlayback(app);
|
||||||
return app.firstElementChild;
|
return app.firstElementChild;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -143,6 +180,7 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
if (typeof syncRtmpRegions === 'function') {
|
if (typeof syncRtmpRegions === 'function') {
|
||||||
syncRtmpRegions(nextShell);
|
syncRtmpRegions(nextShell);
|
||||||
}
|
}
|
||||||
|
initializeRenderedVideoPlayback(nextShell);
|
||||||
return nextShell;
|
return nextShell;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -162,6 +200,8 @@ function renderSlideMarkup(markup, shouldFade) {
|
|||||||
syncRtmpRegions(nextShell);
|
syncRtmpRegions(nextShell);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
initializeRenderedVideoPlayback(nextShell);
|
||||||
|
|
||||||
slideTransitionTimer = window.setTimeout(function () {
|
slideTransitionTimer = window.setTimeout(function () {
|
||||||
if (previousShell && previousShell.parentNode) {
|
if (previousShell && previousShell.parentNode) {
|
||||||
previousShell.parentNode.removeChild(previousShell);
|
previousShell.parentNode.removeChild(previousShell);
|
||||||
|
|||||||
@@ -147,7 +147,7 @@ function refresh() {
|
|||||||
markRefreshHealthy();
|
markRefreshHealthy();
|
||||||
setOfflineBannerVisible(false);
|
setOfflineBannerVisible(false);
|
||||||
if (lastRenderedSlide && getCurrentActiveSlides().length < 2) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -452,6 +452,9 @@ function getTemplateRenderPlan(template) {
|
|||||||
if (region.regionType === 'image') {
|
if (region.regionType === 'image') {
|
||||||
return renderImageRegion(region, regionContent);
|
return renderImageRegion(region, regionContent);
|
||||||
}
|
}
|
||||||
|
if (region.regionType === 'video') {
|
||||||
|
return renderVideoRegion(region, regionContent);
|
||||||
|
}
|
||||||
if (region.regionType === 'webpage') {
|
if (region.regionType === 'webpage') {
|
||||||
return renderWebpageRegion(region, regionContent);
|
return renderWebpageRegion(region, regionContent);
|
||||||
}
|
}
|
||||||
@@ -517,9 +520,21 @@ function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHt
|
|||||||
// Build the cache key for rendered slide markup.
|
// Build the cache key for rendered slide markup.
|
||||||
function getSlideMarkupCacheKey(slide) {
|
function getSlideMarkupCacheKey(slide) {
|
||||||
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
var viewportKey = window.innerWidth + 'x' + window.innerHeight;
|
||||||
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', viewportKey].join('|');
|
return [currentPlaylistSignature || '', slide && slide.id ? slide.id : '', slide && slide.template_id ? slide.template_id : '', slide && slide.modified_at ? slide.modified_at : '', viewportKey, videoRegionRenderVersion || 0].join('|');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function notifyVideoRegionSourceReady() {
|
||||||
|
if (typeof videoRegionRenderVersion === 'number') {
|
||||||
|
videoRegionRenderVersion += 1;
|
||||||
|
}
|
||||||
|
slideMarkupCache = Object.create(null);
|
||||||
|
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||||
|
showCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.notifyVideoRegionSourceReady = notifyVideoRegionSourceReady;
|
||||||
|
|
||||||
// Look up a previously rendered slide in the cache.
|
// Look up a previously rendered slide in the cache.
|
||||||
function getCachedSlideMarkup(slide) {
|
function getCachedSlideMarkup(slide) {
|
||||||
var cacheKey = getSlideMarkupCacheKey(slide);
|
var cacheKey = getSlideMarkupCacheKey(slide);
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
const CACHE_VERSION = 'v1';
|
const CACHE_VERSION = 'v2';
|
||||||
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
const PAGE_CACHE = `pulse-signage-player-pages-${CACHE_VERSION}`;
|
||||||
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
const ASSET_CACHE = `pulse-signage-player-assets-${CACHE_VERSION}`;
|
||||||
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
|
const MEDIA_CACHE = `pulse-signage-player-media-${CACHE_VERSION}`;
|
||||||
@@ -87,6 +87,10 @@ async function staleWhileRevalidate(request, cacheName) {
|
|||||||
return new Response('', { status: 504, statusText: 'Offline' });
|
return new Response('', { status: 504, statusText: 'Offline' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function networkOnly(request) {
|
||||||
|
return fetch(request);
|
||||||
|
}
|
||||||
|
|
||||||
self.addEventListener('install', function (event) {
|
self.addEventListener('install', function (event) {
|
||||||
self.skipWaiting();
|
self.skipWaiting();
|
||||||
event.waitUntil(Promise.resolve());
|
event.waitUntil(Promise.resolve());
|
||||||
@@ -126,7 +130,7 @@ self.addEventListener('fetch', function (event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (url.pathname.startsWith('/media/')) {
|
if (url.pathname.startsWith('/media/')) {
|
||||||
event.respondWith(staleWhileRevalidate(request, MEDIA_CACHE));
|
event.respondWith(networkOnly(request));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
var videoRegionLastGoodSrcCache = Object.create(null);
|
||||||
|
var videoRegionProbeStateCache = Object.create(null);
|
||||||
|
var videoRegionProbeTimerCache = Object.create(null);
|
||||||
|
var VIDEO_REGION_RETRY_DELAY_MS = 5000;
|
||||||
|
|
||||||
|
function getVideoRegionCacheKey(region) {
|
||||||
|
return String(region && (region.regionKey || region.label) || '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDirectlyRenderableSource(src) {
|
||||||
|
return /^(?:https?:)?\/\//i.test(src) || /^data:/i.test(src) || /^blob:/i.test(src) || /^\/media\//i.test(src);
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendCacheBust(src, cacheBust) {
|
||||||
|
var key = String(cacheBust || '').trim();
|
||||||
|
var raw = String(src || '').trim();
|
||||||
|
if (!raw || !key) {
|
||||||
|
return raw;
|
||||||
|
}
|
||||||
|
return raw + (raw.indexOf('?') === -1 ? '?' : '&') + 'v=' + encodeURIComponent(key);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoSourceAvailability(src) {
|
||||||
|
return videoRegionProbeStateCache[String(src || '').trim()] || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVideoSourceAvailability(src, available) {
|
||||||
|
var key = String(src || '').trim();
|
||||||
|
if (!key) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
videoRegionProbeStateCache[key] = {
|
||||||
|
available: available === null ? null : Boolean(available),
|
||||||
|
checkedAt: Date.now()
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function logVideoRegionStatus(message, details, level) {
|
||||||
|
if (typeof logDebug === 'function') {
|
||||||
|
logDebug(message, details || '', level || 'info');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function triggerVideoRegionSourceRefresh() {
|
||||||
|
if (typeof window.notifyVideoRegionSourceReady === 'function') {
|
||||||
|
window.notifyVideoRegionSourceReady();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof videoRegionRenderVersion === 'number') {
|
||||||
|
videoRegionRenderVersion += 1;
|
||||||
|
}
|
||||||
|
slideMarkupCache = Object.create(null);
|
||||||
|
if (typeof showCurrent === 'function' && slides && slides.length) {
|
||||||
|
showCurrent();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function scheduleVideoSourceProbe(regionKey, src, isRetry) {
|
||||||
|
var key = String(src || '').trim();
|
||||||
|
if (!regionKey || !key || isDirectlyRenderableSource(key)) {
|
||||||
|
if (key) {
|
||||||
|
setVideoSourceAvailability(key, true);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (videoRegionProbeTimerCache[key]) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isRetry) {
|
||||||
|
logVideoRegionStatus('Video source changed; probing mirrored file before swapping.', 'region=' + regionKey + ' src=' + key);
|
||||||
|
} else {
|
||||||
|
logVideoRegionStatus('Video source still unavailable; retrying mirrored file probe.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||||
|
}
|
||||||
|
|
||||||
|
videoRegionProbeTimerCache[key] = window.setTimeout(function () {
|
||||||
|
delete videoRegionProbeTimerCache[key];
|
||||||
|
fetch(key, {
|
||||||
|
method: 'HEAD',
|
||||||
|
cache: 'no-store',
|
||||||
|
credentials: 'same-origin'
|
||||||
|
}).then(function (response) {
|
||||||
|
if (response && response.ok) {
|
||||||
|
setVideoSourceAvailability(key, true);
|
||||||
|
if (videoRegionLastGoodSrcCache[regionKey] !== key) {
|
||||||
|
videoRegionLastGoodSrcCache[regionKey] = key;
|
||||||
|
logVideoRegionStatus('Mirrored video is ready; switching to the new source.', 'region=' + regionKey + ' src=' + key);
|
||||||
|
triggerVideoRegionSourceRefresh();
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoSourceAvailability(key, false);
|
||||||
|
logVideoRegionStatus('Mirrored video probe returned unavailable; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||||
|
scheduleVideoSourceProbe(regionKey, key, true);
|
||||||
|
}).catch(function () {
|
||||||
|
setVideoSourceAvailability(key, false);
|
||||||
|
logVideoRegionStatus('Mirrored video probe failed; keeping the old source for now.', 'region=' + regionKey + ' src=' + key, 'warn');
|
||||||
|
scheduleVideoSourceProbe(regionKey, key, true);
|
||||||
|
});
|
||||||
|
}, isRetry ? VIDEO_REGION_RETRY_DELAY_MS : 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderVideoRegion(region, regionContent) {
|
||||||
|
var requestedSrc = String(regionContent && regionContent.value || '').trim();
|
||||||
|
var requestedSrcVersioned = appendCacheBust(requestedSrc, regionContent && regionContent.cache_bust);
|
||||||
|
var regionKey = getVideoRegionCacheKey(region);
|
||||||
|
var cachedSrc = regionKey ? String(videoRegionLastGoodSrcCache[regionKey] || '').trim() : '';
|
||||||
|
var cachedSrcVersioned = appendCacheBust(cachedSrc, regionContent && regionContent.cache_bust);
|
||||||
|
|
||||||
|
if (!requestedSrc) {
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isDirectlyRenderableSource(requestedSrc)) {
|
||||||
|
if (regionKey) {
|
||||||
|
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||||
|
}
|
||||||
|
setVideoSourceAvailability(requestedSrc, true);
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
var requestedState = getVideoSourceAvailability(requestedSrc);
|
||||||
|
var requestedReady = Boolean(requestedState && requestedState.available === true);
|
||||||
|
|
||||||
|
if (requestedReady) {
|
||||||
|
if (regionKey) {
|
||||||
|
videoRegionLastGoodSrcCache[regionKey] = requestedSrc;
|
||||||
|
}
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(requestedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleVideoSourceProbe(regionKey, requestedSrc, false);
|
||||||
|
|
||||||
|
if (cachedSrc) {
|
||||||
|
if (cachedSrc !== requestedSrc) {
|
||||||
|
logVideoRegionStatus('Keeping the previous playable video until the new mirrored file finishes transferring.', 'region=' + regionKey + ' old=' + cachedSrc + ' new=' + requestedSrc);
|
||||||
|
}
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><video src="' + escapeHtml(cachedSrcVersioned) + '" title="' + escapeHtml(region.label) + '" autoplay muted loop playsinline preload="auto" disablepictureinpicture oncanplay="this.play().catch(function () {})" onloadedmetadata="this.play().catch(function () {})"></video></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<div class="template-region video" style="' + region.baseStyle + '"><div class="template-region-placeholder">Video</div></div>';
|
||||||
|
}
|
||||||
@@ -286,6 +286,7 @@ const playerPagePlaybackScriptPath = path.join(__dirname, 'public', 'js', 'playe
|
|||||||
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
const playerPageScriptPath = path.join(__dirname, 'player-page.script.html');
|
||||||
const playerRegionScriptPaths = [
|
const playerRegionScriptPaths = [
|
||||||
path.join(__dirname, 'regions', 'image.js'),
|
path.join(__dirname, 'regions', 'image.js'),
|
||||||
|
path.join(__dirname, 'regions', 'video.js'),
|
||||||
path.join(__dirname, 'regions', 'webpage.js'),
|
path.join(__dirname, 'regions', 'webpage.js'),
|
||||||
path.join(__dirname, 'regions', 'html.js'),
|
path.join(__dirname, 'regions', 'html.js'),
|
||||||
path.join(__dirname, 'regions', 'rtmp.js'),
|
path.join(__dirname, 'regions', 'rtmp.js'),
|
||||||
|
|||||||
@@ -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 {
|
try {
|
||||||
const filePath = resolveMediaFilePath(req.params.filename);
|
const filePath = resolveMediaFilePath(req.params.filename);
|
||||||
if (!filePath) {
|
if (!filePath) {
|
||||||
|
|||||||
@@ -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') {
|
if (regionType === 'webpage') {
|
||||||
const src = resolveAssetUrl(baseUrl, rawValue);
|
const src = resolveAssetUrl(baseUrl, rawValue);
|
||||||
return src
|
return src
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ function createUploadSyncService(options) {
|
|||||||
const playerSnapshotCache = options && options.playerSnapshotCache;
|
const playerSnapshotCache = options && options.playerSnapshotCache;
|
||||||
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
const notifyPlayerScreens = options && options.notifyPlayerScreens;
|
||||||
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
const backgroundTaskQueue = options && options.backgroundTaskQueue;
|
||||||
const MAX_UPLOAD_BYTES = 100 * 1024 * 1024;
|
const MAX_UPLOAD_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
let playerUploadSyncMode = null;
|
let playerUploadSyncMode = null;
|
||||||
let playerUploadSyncModePromise = null;
|
let playerUploadSyncModePromise = null;
|
||||||
@@ -627,6 +627,10 @@ function createUploadSyncService(options) {
|
|||||||
if (mode === 'playlist') {
|
if (mode === 'playlist') {
|
||||||
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
const operation = normalizePlaylistUploadSyncOperation(taskPayload.operation || taskPayload);
|
||||||
|
|
||||||
|
if (operation.nextUploadRefs.length) {
|
||||||
|
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
||||||
|
}
|
||||||
|
|
||||||
if (operation.previousUploadRefs.length) {
|
if (operation.previousUploadRefs.length) {
|
||||||
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
const nextUploadRefSet = new Set(operation.nextUploadRefs);
|
||||||
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
await removeUnusedUploadFiles(pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
|
||||||
@@ -634,10 +638,6 @@ function createUploadSyncService(options) {
|
|||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (operation.nextUploadRefs.length) {
|
|
||||||
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (operation.refreshScreenSlugs.length) {
|
if (operation.refreshScreenSlugs.length) {
|
||||||
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds, operation.screenSlideCounts);
|
||||||
if (refreshTargets.ready.length) {
|
if (refreshTargets.ready.length) {
|
||||||
|
|||||||
@@ -759,6 +759,13 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
display: block;
|
display: block;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slide-preview-video {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-preview-background {
|
.slide-preview-background {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -1014,6 +1021,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
border: 0;
|
border: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slide-image-region-preview-shell video.slide-image-region-preview {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-image-region-preview-empty {
|
.slide-image-region-preview-empty {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -1039,7 +1050,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
position: relative;
|
position: relative;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
min-height: 18rem;
|
min-height: 18rem;
|
||||||
overflow: hidden;
|
/* overflow: hidden; */
|
||||||
border: 1px solid var(--bs-border-color);
|
border: 1px solid var(--bs-border-color);
|
||||||
border-radius: 0;
|
border-radius: 0;
|
||||||
background: var(--bs-body-bg);
|
background: var(--bs-body-bg);
|
||||||
@@ -1068,7 +1079,7 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
|
|
||||||
.designer-overlay {
|
.designer-overlay {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
cursor: crosshair;
|
cursor: default;
|
||||||
}
|
}
|
||||||
|
|
||||||
.designer-rect {
|
.designer-rect {
|
||||||
@@ -1076,9 +1087,10 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
border: 2px solid rgba(13, 110, 253, 0.95);
|
border: 2px solid rgba(13, 110, 253, 0.95);
|
||||||
background: rgba(13, 110, 253, 0.12);
|
background: rgba(13, 110, 253, 0.12);
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
border-radius: 0.35rem;
|
border-radius: 0;
|
||||||
min-width: 12px;
|
min-width: 12px;
|
||||||
min-height: 12px;
|
min-height: 12px;
|
||||||
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
.designer-rect.selected {
|
.designer-rect.selected {
|
||||||
@@ -1088,12 +1100,12 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
|
|
||||||
.designer-rect-label {
|
.designer-rect-label {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0;
|
left: 0.4rem;
|
||||||
top: -1.55rem;
|
top: 0.35rem;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
padding: 0.15rem 0.45rem;
|
padding: 0.15rem 0.45rem;
|
||||||
border-radius: 999px;
|
border-radius: 999px;
|
||||||
background: rgba(33, 37, 41, 0.88);
|
background: rgba(33, 37, 41, 0.92);
|
||||||
color: #fff;
|
color: #fff;
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
line-height: 1.2;
|
line-height: 1.2;
|
||||||
@@ -1117,26 +1129,30 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.nw {
|
.resize-handle.nw {
|
||||||
left: -0.4rem;
|
left: 0;
|
||||||
top: -0.4rem;
|
top: 0;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.ne {
|
.resize-handle.ne {
|
||||||
right: -0.4rem;
|
right: 0;
|
||||||
top: -0.4rem;
|
top: 0;
|
||||||
|
transform: translate(50%, -50%);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.sw {
|
.resize-handle.sw {
|
||||||
left: -0.4rem;
|
left: 0;
|
||||||
bottom: -0.4rem;
|
bottom: 0;
|
||||||
|
transform: translate(-50%, 50%);
|
||||||
cursor: nesw-resize;
|
cursor: nesw-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
.resize-handle.se {
|
.resize-handle.se {
|
||||||
right: -0.4rem;
|
right: 0;
|
||||||
bottom: -0.4rem;
|
bottom: 0;
|
||||||
|
transform: translate(50%, 50%);
|
||||||
cursor: nwse-resize;
|
cursor: nwse-resize;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1220,7 +1236,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
gap: 1rem;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-info-card .card-title {
|
.region-info-card .card-title {
|
||||||
@@ -1366,8 +1381,14 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-item .remove-region {
|
.region-item [data-region-remove-button] {
|
||||||
justify-self: end;
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.region-item [data-region-remove-button]:disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
box-shadow: none;
|
||||||
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
.region-item .form-control:focus,
|
.region-item .form-control:focus,
|
||||||
@@ -1575,10 +1596,6 @@ html[data-bs-theme='dark'] .ck-body-wrapper .ck.ck-color-grid__tile {
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.playlist-slide-picker-meta {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.playlist-slide-picker-check {
|
.playlist-slide-picker-check {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 0.5rem;
|
top: 0.5rem;
|
||||||
@@ -1680,6 +1697,34 @@ td[data-label="Actions"] > div {
|
|||||||
min-width: 6rem;
|
min-width: 6rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.playlist-duration-input::-webkit-outer-spin-button,
|
||||||
|
.playlist-duration-input::-webkit-inner-spin-button {
|
||||||
|
-webkit-appearance: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-duration-input {
|
||||||
|
-moz-appearance: textfield;
|
||||||
|
appearance: textfield;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-duration-field {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.playlist-use-video-duration {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
td[data-label="Duration"] {
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
table.table > :not(caption) > * > * {
|
table.table > :not(caption) > * > * {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
@@ -1818,16 +1863,6 @@ table.table thead th.sort-desc .table-sort-indicator {
|
|||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dashboard-hero-body {
|
|
||||||
flex-direction: column;
|
|
||||||
align-items: stretch;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dashboard-hero-stats {
|
|
||||||
grid-template-columns: 1fr;
|
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.page-header {
|
.page-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
|||||||
@@ -1,599 +0,0 @@
|
|||||||
(function () {
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return String(value ?? '')
|
|
||||||
.replace(/&/g, '&')
|
|
||||||
.replace(/</g, '<')
|
|
||||||
.replace(/>/g, '>')
|
|
||||||
.replace(/"/g, '"')
|
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDashboardDate(value) {
|
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
var date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientRowKey(client) {
|
|
||||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientDisplayName(client) {
|
|
||||||
if (client && client.client_name) {
|
|
||||||
return String(client.client_name).trim();
|
|
||||||
}
|
|
||||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
|
||||||
if (clientId) {
|
|
||||||
return clientId;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (button.classList) {
|
|
||||||
classesToRemove.forEach(function (className) {
|
|
||||||
button.classList.remove(className);
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
button.classList.add(classToAdd);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className = String(button.className || '');
|
|
||||||
classesToRemove.forEach(function (removeClass) {
|
|
||||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
className += ' ' + classToAdd;
|
|
||||||
}
|
|
||||||
button.className = className.replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDisplayIp(value) {
|
|
||||||
var ip = String(value || '').trim();
|
|
||||||
if (!ip) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
|
||||||
return ip.slice(7).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initConfirmForms() {
|
|
||||||
document.addEventListener('submit', function (event) {
|
|
||||||
var form = event.target;
|
|
||||||
if (!form || !form.getAttribute) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.hasAttribute && form.hasAttribute('data-async-command')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var message = form.getAttribute('data-confirm-message');
|
|
||||||
if (message && !window.confirm(message)) {
|
|
||||||
event.preventDefault();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function markFormDirty(form) {
|
|
||||||
if (!form || !form.hasAttribute || form.hasAttribute('data-clean-on-load')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.dataset.dirty = 'true';
|
|
||||||
}
|
|
||||||
|
|
||||||
function clearFormDirty(form) {
|
|
||||||
if (!form) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
form.dataset.dirty = 'false';
|
|
||||||
}
|
|
||||||
|
|
||||||
function isFormDirty(form) {
|
|
||||||
return Boolean(form && form.dataset && form.dataset.dirty === 'true');
|
|
||||||
}
|
|
||||||
|
|
||||||
function initDirtyTracking() {
|
|
||||||
document.addEventListener('input', function (event) {
|
|
||||||
var target = event.target;
|
|
||||||
if (!target || !target.form) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
markFormDirty(target.form);
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
document.addEventListener('change', function (event) {
|
|
||||||
var target = event.target;
|
|
||||||
if (!target || !target.form) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
markFormDirty(target.form);
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initCancelConfirm() {
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
var cancelTarget = event.target.closest('[data-confirm-unsaved]');
|
|
||||||
if (!cancelTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var form = cancelTarget.form || cancelTarget.closest('form') || document.querySelector('form[data-dirty="true"]');
|
|
||||||
if (!isFormDirty(form)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var message = cancelTarget.getAttribute('data-confirm-unsaved') || 'You have unsaved changes. Leave this page?';
|
|
||||||
if (!window.confirm(message)) {
|
|
||||||
event.preventDefault();
|
|
||||||
event.stopPropagation();
|
|
||||||
}
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initAsyncCommandForms() {
|
|
||||||
document.addEventListener('submit', function (event) {
|
|
||||||
var form = event.target;
|
|
||||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-command')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.dataset && form.dataset.busy === 'true') {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var message = form.getAttribute('data-confirm-message');
|
|
||||||
if (message && !window.confirm(message)) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
|
|
||||||
form.dataset.busy = 'true';
|
|
||||||
|
|
||||||
var formData = new FormData(form);
|
|
||||||
var body = new URLSearchParams();
|
|
||||||
formData.forEach(function (value, key) {
|
|
||||||
body.append(key, value);
|
|
||||||
});
|
|
||||||
|
|
||||||
fetch(form.action, {
|
|
||||||
method: (form.method || 'POST').toUpperCase(),
|
|
||||||
headers: {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8',
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'application/json, text/plain, */*'
|
|
||||||
},
|
|
||||||
body: body.toString(),
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).finally(function () {
|
|
||||||
delete form.dataset.busy;
|
|
||||||
});
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initAsyncSaveForms() {
|
|
||||||
var refreshSequence = 0;
|
|
||||||
|
|
||||||
function setSaveActionValue(form, value) {
|
|
||||||
if (!form) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var hiddenInput = form.querySelector('input[type="hidden"][name="save_action"]');
|
|
||||||
if (!hiddenInput) {
|
|
||||||
hiddenInput = document.createElement('input');
|
|
||||||
hiddenInput.type = 'hidden';
|
|
||||||
hiddenInput.name = 'save_action';
|
|
||||||
form.appendChild(hiddenInput);
|
|
||||||
}
|
|
||||||
|
|
||||||
hiddenInput.value = String(value || '').trim().toLowerCase();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getResponseQueryValue(responseUrl, key) {
|
|
||||||
try {
|
|
||||||
var url = new URL(responseUrl, window.location.href);
|
|
||||||
return String(url.searchParams.get(key) || '').trim();
|
|
||||||
} catch (_error) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function rebindRefreshTarget(targetElement) {
|
|
||||||
if (!targetElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof window.initJsonTogglePanels === 'function') {
|
|
||||||
window.initJsonTogglePanels(targetElement);
|
|
||||||
}
|
|
||||||
if (typeof window.initLocalDateTimes === 'function') {
|
|
||||||
window.initLocalDateTimes(targetElement);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId) {
|
|
||||||
if (sequenceId !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(window.location.href, {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'text/html, application/xhtml+xml'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-store'
|
|
||||||
}).then(function (response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Unable to refresh saved data.');
|
|
||||||
}
|
|
||||||
return response.text();
|
|
||||||
}).then(function (text) {
|
|
||||||
if (sequenceId !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
|
||||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
|
||||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
|
||||||
if (!currentTarget || !nextTarget) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
|
||||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
|
||||||
}).catch(function (_error) {
|
|
||||||
// Ignore refresh replacement failures and leave the existing content in place.
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, sequenceId) {
|
|
||||||
var pollDelayMs = 1000;
|
|
||||||
var maxAttempts = 60;
|
|
||||||
|
|
||||||
function poll(attempt) {
|
|
||||||
if (sequenceId !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var stateUrl;
|
|
||||||
try {
|
|
||||||
stateUrl = new URL(refreshStateUrl, window.location.href);
|
|
||||||
} catch (_error) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stateUrl.searchParams.set('refresh_task_id', refreshTaskId);
|
|
||||||
|
|
||||||
fetch(stateUrl.toString(), {
|
|
||||||
method: 'GET',
|
|
||||||
headers: {
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'application/json, text/plain, */*'
|
|
||||||
},
|
|
||||||
credentials: 'same-origin',
|
|
||||||
cache: 'no-store'
|
|
||||||
}).then(function (response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
throw new Error('Unable to check refresh status.');
|
|
||||||
}
|
|
||||||
return response.json();
|
|
||||||
}).then(function (payload) {
|
|
||||||
if (sequenceId !== refreshSequence) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var status = String(payload && payload.status || '').trim().toLowerCase();
|
|
||||||
if (status === 'queued' || status === 'running') {
|
|
||||||
if (attempt < maxAttempts) {
|
|
||||||
window.setTimeout(function () {
|
|
||||||
poll(attempt + 1);
|
|
||||||
}, pollDelayMs);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
replaceRefreshTargetFromPage(refreshTargetSelector, sequenceId);
|
|
||||||
}).catch(function () {
|
|
||||||
if (attempt < maxAttempts) {
|
|
||||||
window.setTimeout(function () {
|
|
||||||
poll(attempt + 1);
|
|
||||||
}, pollDelayMs);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
poll(0);
|
|
||||||
}
|
|
||||||
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
var target = event.target;
|
|
||||||
if (!target || !target.closest) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var button = target.closest('button[name="save_action"]');
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var form = button.form || button.closest('form');
|
|
||||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setSaveActionValue(form, button.value || '');
|
|
||||||
form.dataset.submitterValue = String(button.value || '').trim().toLowerCase();
|
|
||||||
}, true);
|
|
||||||
|
|
||||||
document.addEventListener('submit', function (event) {
|
|
||||||
var form = event.target;
|
|
||||||
if (!form || !form.hasAttribute || !form.hasAttribute('data-async-save')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (form.dataset && form.dataset.busy === 'true') {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var message = form.getAttribute('data-confirm-message');
|
|
||||||
if (message && !window.confirm(message)) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
event.preventDefault();
|
|
||||||
form.dataset.busy = 'true';
|
|
||||||
|
|
||||||
var hiddenSaveAction = form.querySelector('input[type="hidden"][name="save_action"]');
|
|
||||||
var formData = new FormData(form);
|
|
||||||
var submitterValue = String((hiddenSaveAction && hiddenSaveAction.value) || form.dataset.submitterValue || '').trim().toLowerCase();
|
|
||||||
if (event.submitter && event.submitter.name) {
|
|
||||||
submitterValue = String(event.submitter.value || '').trim().toLowerCase();
|
|
||||||
formData.set(event.submitter.name, event.submitter.value || '');
|
|
||||||
}
|
|
||||||
var hasFileValue = false;
|
|
||||||
formData.forEach(function (value) {
|
|
||||||
if (value && typeof value === 'object' && typeof value.name === 'string') {
|
|
||||||
hasFileValue = true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
var isMultipart = hasFileValue || String(form.enctype || '').toLowerCase() === 'multipart/form-data';
|
|
||||||
var body = isMultipart ? formData : new URLSearchParams();
|
|
||||||
|
|
||||||
if (!isMultipart) {
|
|
||||||
formData.forEach(function (value, key) {
|
|
||||||
body.append(key, value);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
fetch(form.action, {
|
|
||||||
method: (form.method || 'POST').toUpperCase(),
|
|
||||||
headers: Object.assign({
|
|
||||||
'X-Requested-With': 'XMLHttpRequest',
|
|
||||||
'Accept': 'text/html, application/json, text/plain, */*'
|
|
||||||
}, isMultipart ? {} : {
|
|
||||||
'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8'
|
|
||||||
}),
|
|
||||||
body: isMultipart ? body : body.toString(),
|
|
||||||
credentials: 'same-origin'
|
|
||||||
}).then(function (response) {
|
|
||||||
if (!response.ok) {
|
|
||||||
return response.text().then(function (text) {
|
|
||||||
var error = new Error(text || 'Unable to save changes.');
|
|
||||||
error.status = response.status;
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
var actionUrl = '';
|
|
||||||
try {
|
|
||||||
actionUrl = new URL(form.action, window.location.href).pathname;
|
|
||||||
} catch (_error) {
|
|
||||||
actionUrl = String(form.action || '');
|
|
||||||
}
|
|
||||||
var refreshTargetSelector = String(form.getAttribute('data-async-save-refresh-target') || '').trim();
|
|
||||||
var refreshStateUrl = String(form.getAttribute('data-async-save-refresh-state-url') || '').trim();
|
|
||||||
var refreshTaskId = getResponseQueryValue(response.url || '', 'refresh_task_id');
|
|
||||||
var shouldFollowRedirect = Boolean(form.hasAttribute('data-async-save-new-url')) && !/\/\d+(?:\/|$)/.test(actionUrl);
|
|
||||||
if (submitterValue === 'close' || submitterValue === 'new') {
|
|
||||||
var redirectUrl = submitterValue === 'close'
|
|
||||||
? String(form.dataset.asyncSaveCloseUrl || response.url || window.location.href)
|
|
||||||
: String(form.dataset.asyncSaveNewUrl || response.url || window.location.href);
|
|
||||||
window.location.replace(redirectUrl);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (shouldFollowRedirect && response.url) {
|
|
||||||
clearFormDirty(form);
|
|
||||||
window.location.replace(response.url);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (form.hasAttribute('data-async-save-reload-on-success')) {
|
|
||||||
clearFormDirty(form);
|
|
||||||
window.location.replace(response.url || window.location.href);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
clearFormDirty(form);
|
|
||||||
return response.text().then(function (text) {
|
|
||||||
var savedMessage = '';
|
|
||||||
var responseDocument = null;
|
|
||||||
try {
|
|
||||||
responseDocument = new DOMParser().parseFromString(text || '', 'text/html');
|
|
||||||
var toastBody = responseDocument.querySelector('.toast-body');
|
|
||||||
if (toastBody && toastBody.textContent) {
|
|
||||||
savedMessage = toastBody.textContent.trim();
|
|
||||||
}
|
|
||||||
} catch (_error) {
|
|
||||||
savedMessage = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (refreshTargetSelector && refreshStateUrl && refreshTaskId) {
|
|
||||||
refreshSequence += 1;
|
|
||||||
watchRefreshTask(refreshStateUrl, refreshTaskId, refreshTargetSelector, refreshSequence);
|
|
||||||
} else if (refreshTargetSelector && responseDocument) {
|
|
||||||
var currentTarget = document.querySelector(refreshTargetSelector);
|
|
||||||
var nextTarget = responseDocument.querySelector(refreshTargetSelector);
|
|
||||||
if (currentTarget && nextTarget) {
|
|
||||||
currentTarget.outerHTML = nextTarget.outerHTML;
|
|
||||||
rebindRefreshTarget(document.querySelector(refreshTargetSelector));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
showToast(savedMessage || 'Saved.', 'success');
|
|
||||||
});
|
|
||||||
}).catch(function (error) {
|
|
||||||
if (typeof showToast === 'function') {
|
|
||||||
var variant = Number(error && error.status) >= 400 && Number(error && error.status) < 500 ? 'warning' : 'danger';
|
|
||||||
showToast(error.message || 'Unable to save changes.', variant);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.alert(error.message || 'Unable to save changes.');
|
|
||||||
}).finally(function () {
|
|
||||||
delete form.dataset.busy;
|
|
||||||
delete form.dataset.submitterValue;
|
|
||||||
if (hiddenSaveAction) {
|
|
||||||
hiddenSaveAction.value = '';
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}, true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function initSubmitOnChange() {
|
|
||||||
var fields = document.querySelectorAll('[data-submit-on-change]');
|
|
||||||
Array.prototype.forEach.call(fields, function (field) {
|
|
||||||
field.addEventListener('change', function () {
|
|
||||||
var form = field.form || field.closest('form');
|
|
||||||
if (form) {
|
|
||||||
form.submit();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initJsonTogglePanels(root) {
|
|
||||||
var scope = root && root.querySelectorAll ? root : document;
|
|
||||||
var panels = scope.querySelectorAll('[data-json-toggle-panel]');
|
|
||||||
Array.prototype.forEach.call(panels, function (panel) {
|
|
||||||
var output = panel.querySelector('[data-json-toggle-output]');
|
|
||||||
if (!output) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var card = panel.closest ? panel.closest('.card') : null;
|
|
||||||
var button = card ? card.querySelector('[data-json-toggle]') : null;
|
|
||||||
var label = button ? button.querySelector('[data-json-toggle-label]') : null;
|
|
||||||
var sourceNode = panel.querySelector('[data-json-toggle-source]');
|
|
||||||
var rawJson = '';
|
|
||||||
try {
|
|
||||||
rawJson = JSON.parse(String(sourceNode ? sourceNode.textContent : '""'));
|
|
||||||
} catch (_error) {
|
|
||||||
rawJson = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof rawJson !== 'string' || !rawJson.trim()) {
|
|
||||||
if (button) {
|
|
||||||
button.classList.add('d-none');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var parsedJson;
|
|
||||||
try {
|
|
||||||
parsedJson = JSON.parse(rawJson);
|
|
||||||
} catch (_error) {
|
|
||||||
if (button) {
|
|
||||||
button.classList.add('d-none');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var compactJson = JSON.stringify(parsedJson);
|
|
||||||
var formattedJson = JSON.stringify(parsedJson, null, 2);
|
|
||||||
var isFormatted = true;
|
|
||||||
|
|
||||||
function syncButtonLabel() {
|
|
||||||
if (!button || !label) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
label.textContent = isFormatted
|
|
||||||
? String(button.getAttribute('data-json-toggle-label-compact') || 'Unformat JSON')
|
|
||||||
: String(button.getAttribute('data-json-toggle-label-formatted') || 'Format JSON');
|
|
||||||
button.setAttribute('aria-pressed', isFormatted ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncOutput() {
|
|
||||||
output.textContent = isFormatted ? formattedJson : compactJson;
|
|
||||||
syncButtonLabel();
|
|
||||||
}
|
|
||||||
|
|
||||||
output.textContent = formattedJson;
|
|
||||||
syncButtonLabel();
|
|
||||||
|
|
||||||
if (button) {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
isFormatted = !isFormatted;
|
|
||||||
syncOutput();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function initLocalDateTimes(root) {
|
|
||||||
var scope = root && root.querySelectorAll ? root : document;
|
|
||||||
var elements = scope.querySelectorAll('[data-local-datetime]');
|
|
||||||
Array.prototype.forEach.call(elements, function (element) {
|
|
||||||
var rawValue = String(element.getAttribute('datetime') || element.getAttribute('data-local-datetime') || element.textContent || '').trim();
|
|
||||||
if (!rawValue) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var date = new Date(rawValue);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
element.textContent = formatDashboardDate(date);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (window.initSortableTables) {
|
|
||||||
window.initSortableTables();
|
|
||||||
}
|
|
||||||
|
|
||||||
initConfirmForms();
|
|
||||||
initDirtyTracking();
|
|
||||||
initCancelConfirm();
|
|
||||||
initAsyncCommandForms();
|
|
||||||
initAsyncSaveForms();
|
|
||||||
initSubmitOnChange();
|
|
||||||
initJsonTogglePanels();
|
|
||||||
initLocalDateTimes();
|
|
||||||
window.initJsonTogglePanels = initJsonTogglePanels;
|
|
||||||
window.initLocalDateTimes = initLocalDateTimes;
|
|
||||||
}());
|
|
||||||
@@ -1,84 +1,11 @@
|
|||||||
(function () {
|
(function () {
|
||||||
|
var webUiHelpers = window.webUiHelpers || {};
|
||||||
function escapeHtml(value) {
|
var escapeHtml = webUiHelpers.escapeHtml;
|
||||||
return String(value ?? '')
|
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||||
.replace(/&/g, '&')
|
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||||
.replace(/</g, '<')
|
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||||
.replace(/>/g, '>')
|
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||||
.replace(/"/g, '"')
|
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||||
.replace(/'/g, ''');
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatDashboardDate(value) {
|
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
var date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientRowKey(client) {
|
|
||||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientDisplayName(client) {
|
|
||||||
if (client && client.client_name) {
|
|
||||||
return String(client.client_name).trim();
|
|
||||||
}
|
|
||||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
|
||||||
if (clientId) {
|
|
||||||
return clientId;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (button.classList) {
|
|
||||||
classesToRemove.forEach(function (className) {
|
|
||||||
button.classList.remove(className);
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
button.classList.add(classToAdd);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className = String(button.className || '');
|
|
||||||
classesToRemove.forEach(function (removeClass) {
|
|
||||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
className += ' ' + classToAdd;
|
|
||||||
}
|
|
||||||
button.className = className.replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDisplayIp(value) {
|
|
||||||
var ip = String(value || '').trim();
|
|
||||||
if (!ip) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
|
||||||
return ip.slice(7).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
function initConfirmForms() {
|
function initConfirmForms() {
|
||||||
document.addEventListener('submit', function (event) {
|
document.addEventListener('submit', function (event) {
|
||||||
|
|||||||
@@ -1,83 +1,11 @@
|
|||||||
(function () {
|
(function () {
|
||||||
function escapeHtml(value) {
|
var webUiHelpers = window.webUiHelpers || {};
|
||||||
return String(value ?? '')
|
var escapeHtml = webUiHelpers.escapeHtml;
|
||||||
.replace(/&/g, '&')
|
var formatDashboardDate = webUiHelpers.formatDashboardDate;
|
||||||
.replace(/</g, '<')
|
var getClientRowKey = webUiHelpers.getClientRowKey;
|
||||||
.replace(/>/g, '>')
|
var getClientDisplayName = webUiHelpers.getClientDisplayName;
|
||||||
.replace(/"/g, '"')
|
var setButtonVariant = webUiHelpers.setButtonVariant;
|
||||||
.replace(/'/g, ''');
|
var normalizeDisplayIp = webUiHelpers.normalizeDisplayIp;
|
||||||
}
|
|
||||||
|
|
||||||
function formatDashboardDate(value) {
|
|
||||||
if (!value) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
var date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
return new Intl.DateTimeFormat('en-US', {
|
|
||||||
month: 'short',
|
|
||||||
day: '2-digit',
|
|
||||||
year: 'numeric',
|
|
||||||
hour: 'numeric',
|
|
||||||
minute: '2-digit',
|
|
||||||
second: '2-digit'
|
|
||||||
}).format(date);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientRowKey(client) {
|
|
||||||
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClientDisplayName(client) {
|
|
||||||
if (client && client.client_name) {
|
|
||||||
return String(client.client_name).trim();
|
|
||||||
}
|
|
||||||
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
|
||||||
if (clientId) {
|
|
||||||
return clientId;
|
|
||||||
}
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function setButtonVariant(button, classesToRemove, classToAdd) {
|
|
||||||
if (!button) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (button.classList) {
|
|
||||||
classesToRemove.forEach(function (className) {
|
|
||||||
button.classList.remove(className);
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
button.classList.add(classToAdd);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var className = String(button.className || '');
|
|
||||||
classesToRemove.forEach(function (removeClass) {
|
|
||||||
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
|
||||||
});
|
|
||||||
if (classToAdd) {
|
|
||||||
className += ' ' + classToAdd;
|
|
||||||
}
|
|
||||||
button.className = className.replace(/\s+/g, ' ').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeDisplayIp(value) {
|
|
||||||
var ip = String(value || '').trim();
|
|
||||||
if (!ip) {
|
|
||||||
return '';
|
|
||||||
}
|
|
||||||
|
|
||||||
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
|
||||||
return ip.slice(7).trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
return ip;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderClientActionCell(client) {
|
function renderClientActionCell(client) {
|
||||||
var paused = Boolean(client.paused);
|
var paused = Boolean(client.paused);
|
||||||
|
|||||||
@@ -1,6 +1,26 @@
|
|||||||
(function () {
|
(function () {
|
||||||
|
// Shared constants used across the playlist schedule editor.
|
||||||
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
var DAY_NAMES = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
|
||||||
|
var VIDEO_DURATION_LABEL = 'Use video duration';
|
||||||
|
var VIDEO_DURATION_ACTIVE_LABEL = 'Use Video Duration';
|
||||||
|
var VIDEO_DURATION_LOADING_LABEL = 'Loading...';
|
||||||
|
var PICKER_THUMB_EMPTY_LABEL = 'No thumbnail';
|
||||||
|
var PICKER_ASSIGNED_BADGE_LABEL = 'Already in playlist';
|
||||||
|
var PICKER_CARD_CLASS = 'playlist-slide-picker-card';
|
||||||
|
var PICKER_MEDIA_CLASS = 'playlist-slide-picker-media';
|
||||||
|
var PICKER_TITLE_CLASS = 'playlist-slide-picker-title';
|
||||||
|
var PICKER_CHECK_CLASS = 'playlist-slide-picker-check bi bi-check2-circle';
|
||||||
|
var PICKER_BADGE_CLASS = 'playlist-slide-picker-badge badge text-bg-primary';
|
||||||
|
var SCHEDULE_ROW_FIELD_NAMES = {
|
||||||
|
mode: '[name="schedule_mode[]"]',
|
||||||
|
startDatetime: '[name="schedule_start_datetime[]"]',
|
||||||
|
endDatetime: '[name="schedule_end_datetime[]"]',
|
||||||
|
startTime: '[name="schedule_start_time[]"]',
|
||||||
|
endTime: '[name="schedule_end_time[]"]',
|
||||||
|
daysJson: '[name="schedule_days_json[]"]'
|
||||||
|
};
|
||||||
|
|
||||||
|
// Schedule helpers.
|
||||||
function formatDays(daysValue) {
|
function formatDays(daysValue) {
|
||||||
var days = [];
|
var days = [];
|
||||||
if (Array.isArray(daysValue)) {
|
if (Array.isArray(daysValue)) {
|
||||||
@@ -21,6 +41,192 @@
|
|||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getScheduleRowFields(row) {
|
||||||
|
return {
|
||||||
|
mode: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.mode) : null,
|
||||||
|
startDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startDatetime) : null,
|
||||||
|
endDatetime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endDatetime) : null,
|
||||||
|
startTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.startTime) : null,
|
||||||
|
endTime: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.endTime) : null,
|
||||||
|
daysJson: row ? row.querySelector(SCHEDULE_ROW_FIELD_NAMES.daysJson) : null
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Video duration helpers.
|
||||||
|
function buildVideoDurationButtonMarkup(isPressed, enabled) {
|
||||||
|
if (!enabled) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return '<button type="button" class="btn btn-outline-secondary btn-sm playlist-use-video-duration' + (isPressed ? ' active' : '') + '" data-use-video-duration-button aria-pressed="' + (isPressed ? 'true' : 'false') + '">' + (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL) + '</button>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncVideoDurationButtonState(button, isPressed, label) {
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
button.classList.toggle('active', Boolean(isPressed));
|
||||||
|
button.setAttribute('aria-pressed', isPressed ? 'true' : 'false');
|
||||||
|
button.textContent = label || (isPressed ? VIDEO_DURATION_ACTIVE_LABEL : VIDEO_DURATION_LABEL);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Slide picker helpers.
|
||||||
|
function getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount) {
|
||||||
|
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
||||||
|
return query ? 'No available slides match your search.' : 'No available slides are currently left for this playlist.';
|
||||||
|
}
|
||||||
|
|
||||||
|
return query ? 'No slides match your search.' : 'No slides are currently available for this playlist.';
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSlidePickerCardState(card, isAssigned, isCanvasMismatch) {
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
||||||
|
card.disabled = isAssigned || isCanvasMismatch;
|
||||||
|
card.classList.toggle('is-assigned', isAssigned);
|
||||||
|
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
||||||
|
card.classList.toggle('is-hidden', false);
|
||||||
|
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
function createElementWithClass(tagName, className, textContent) {
|
||||||
|
var element = document.createElement(tagName);
|
||||||
|
|
||||||
|
if (className) {
|
||||||
|
element.className = className;
|
||||||
|
}
|
||||||
|
if (textContent !== undefined) {
|
||||||
|
element.textContent = textContent;
|
||||||
|
}
|
||||||
|
|
||||||
|
return element;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSlidePickerThumbPlaceholder() {
|
||||||
|
var placeholder = createElementWithClass('div', 'playlist-slide-picker-thumb-placeholder');
|
||||||
|
var icon = createElementWithClass('i', 'bi bi-image playlist-slide-picker-thumb-placeholder-icon');
|
||||||
|
var label = createElementWithClass('span', 'playlist-slide-picker-thumb-placeholder-label', PICKER_THUMB_EMPTY_LABEL);
|
||||||
|
|
||||||
|
icon.setAttribute('aria-hidden', 'true');
|
||||||
|
placeholder.appendChild(icon);
|
||||||
|
placeholder.appendChild(label);
|
||||||
|
return placeholder;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSlidePickerThumbImage(slide) {
|
||||||
|
var image = createElementWithClass('img', 'playlist-slide-picker-thumb-image');
|
||||||
|
|
||||||
|
image.loading = 'lazy';
|
||||||
|
image.alt = String(slide.title || 'Slide');
|
||||||
|
image.src = String(slide.thumbnail_path || '');
|
||||||
|
image.addEventListener('error', function () {
|
||||||
|
if (image.parentNode) {
|
||||||
|
image.parentNode.replaceChild(buildSlidePickerThumbPlaceholder(), image);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return image;
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildSlidePickerMedia(slide) {
|
||||||
|
var media = createElementWithClass('div', PICKER_MEDIA_CLASS);
|
||||||
|
var check = createElementWithClass('span', PICKER_CHECK_CLASS);
|
||||||
|
var badge = createElementWithClass('span', PICKER_BADGE_CLASS, PICKER_ASSIGNED_BADGE_LABEL);
|
||||||
|
|
||||||
|
check.setAttribute('aria-hidden', 'true');
|
||||||
|
badge.setAttribute('aria-hidden', 'true');
|
||||||
|
|
||||||
|
media.appendChild(slide && slide.thumbnail_path ? buildSlidePickerThumbImage(slide) : buildSlidePickerThumbPlaceholder());
|
||||||
|
media.appendChild(check);
|
||||||
|
media.appendChild(badge);
|
||||||
|
|
||||||
|
return media;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Playlist row helpers.
|
||||||
|
function getPlaylistSlideRow(target) {
|
||||||
|
return target && target.closest ? target.closest('tr[data-playlist-slide-row]') : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPlaylistSlideRowKey(row) {
|
||||||
|
return String(row && row.getAttribute('data-row-key') || '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDurationControl(target) {
|
||||||
|
return Boolean(target && target.closest && target.closest('.playlist-use-video-duration'));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getDurationPointerState(target, type) {
|
||||||
|
var row = getPlaylistSlideRow(target);
|
||||||
|
var pointerType = type === 'button' ? 'button' : 'input';
|
||||||
|
|
||||||
|
if (!row) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: pointerType,
|
||||||
|
rowKey: getPlaylistSlideRowKey(row)
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistOrderCellMarkup() {
|
||||||
|
return '' +
|
||||||
|
'<td class="playlist-order-cell" data-label="Order">' +
|
||||||
|
'<div class="playlist-order-cell-inner">' +
|
||||||
|
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
|
||||||
|
'<span class="playlist-order-number"></span>' +
|
||||||
|
'</div>' +
|
||||||
|
'</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) {
|
||||||
|
return '' +
|
||||||
|
'<td data-label="Slide">' +
|
||||||
|
'<div class="playlist-slide-cell">' +
|
||||||
|
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
||||||
|
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" />' +
|
||||||
|
'</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistScheduleCellMarkup(values) {
|
||||||
|
return '' +
|
||||||
|
'<td>' +
|
||||||
|
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
||||||
|
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
||||||
|
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
|
||||||
|
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
|
||||||
|
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
|
||||||
|
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
||||||
|
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
||||||
|
'</td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistDurationCellMarkup(values, durationActionMarkup) {
|
||||||
|
return '' +
|
||||||
|
'<td><div class="playlist-duration-field">' +
|
||||||
|
'<input name="duration_seconds[]" type="text" inputmode="decimal" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end"' + (values.useVideoDuration ? ' disabled' : '') + ' />' +
|
||||||
|
'<input type="hidden" name="use_video_duration[]" value="' + (values.useVideoDuration ? '1' : '0') + '" form="playlist-edit-form" />' +
|
||||||
|
(values.useVideoDuration ? '<input type="hidden" name="duration_seconds[]" value="' + values.duration_seconds + '" form="playlist-edit-form" data-video-duration-mirror />' : '') +
|
||||||
|
durationActionMarkup +
|
||||||
|
'</div></td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildPlaylistActionsCellMarkup(playlistId, rowKey) {
|
||||||
|
return '' +
|
||||||
|
'<td><div class="actions playlist-item-actions">' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
||||||
|
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
||||||
|
'</div></td>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Schedule modal wiring.
|
||||||
function initPlaylistScheduleModal() {
|
function initPlaylistScheduleModal() {
|
||||||
var dialog = document.getElementById('slide-schedule-dialog');
|
var dialog = document.getElementById('slide-schedule-dialog');
|
||||||
var content = document.getElementById('slide-schedule-content');
|
var content = document.getElementById('slide-schedule-content');
|
||||||
@@ -38,14 +244,9 @@
|
|||||||
|
|
||||||
function collectScheduleParams(row, rowKey) {
|
function collectScheduleParams(row, rowKey) {
|
||||||
var params = new URLSearchParams();
|
var params = new URLSearchParams();
|
||||||
var scheduleFields = [
|
var scheduleFields = Object.keys(SCHEDULE_ROW_FIELD_NAMES).map(function (key) {
|
||||||
'schedule_mode[]',
|
return SCHEDULE_ROW_FIELD_NAMES[key].replace(/^\[name="|"\]$/g, '');
|
||||||
'schedule_start_datetime[]',
|
});
|
||||||
'schedule_end_datetime[]',
|
|
||||||
'schedule_start_time[]',
|
|
||||||
'schedule_end_time[]',
|
|
||||||
'schedule_days_json[]'
|
|
||||||
];
|
|
||||||
|
|
||||||
params.set('row_key', String(rowKey || ''));
|
params.set('row_key', String(rowKey || ''));
|
||||||
scheduleFields.forEach(function (fieldName) {
|
scheduleFields.forEach(function (fieldName) {
|
||||||
@@ -113,6 +314,7 @@
|
|||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Schedule form behavior.
|
||||||
function scheduleSummary(values) {
|
function scheduleSummary(values) {
|
||||||
var mode = String(values.schedule_mode || 'always');
|
var mode = String(values.schedule_mode || 'always');
|
||||||
if (mode === 'dates') {
|
if (mode === 'dates') {
|
||||||
@@ -214,10 +416,6 @@
|
|||||||
});
|
});
|
||||||
select.addEventListener('change', clearScheduleValidity);
|
select.addEventListener('change', clearScheduleValidity);
|
||||||
|
|
||||||
function notifyParentResize() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateVisibility() {
|
function updateVisibility() {
|
||||||
var mode = select.value;
|
var mode = select.value;
|
||||||
if (datesPanel) {
|
if (datesPanel) {
|
||||||
@@ -234,9 +432,6 @@
|
|||||||
endTimeInput.value = DEFAULT_END_TIME;
|
endTimeInput.value = DEFAULT_END_TIME;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
window.requestAnimationFrame(function () {
|
|
||||||
notifyParentResize();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
select.addEventListener('change', updateVisibility);
|
select.addEventListener('change', updateVisibility);
|
||||||
@@ -302,6 +497,7 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Playlist editor behavior.
|
||||||
function initPlaylistEditStaging() {
|
function initPlaylistEditStaging() {
|
||||||
var tbody = document.getElementById('playlist-items-body');
|
var tbody = document.getElementById('playlist-items-body');
|
||||||
var form = document.getElementById('playlist-edit-form');
|
var form = document.getElementById('playlist-edit-form');
|
||||||
@@ -317,6 +513,9 @@
|
|||||||
var slidePickerCards = [];
|
var slidePickerCards = [];
|
||||||
var slidePickerSelection = new Set();
|
var slidePickerSelection = new Set();
|
||||||
var slidePickerSlides = [];
|
var slidePickerSlides = [];
|
||||||
|
var videoDurationCache = Object.create(null);
|
||||||
|
var lastDurationPointerDown = null;
|
||||||
|
var lastDurationPointerUp = null;
|
||||||
|
|
||||||
if (!tbody || !form) {
|
if (!tbody || !form) {
|
||||||
return;
|
return;
|
||||||
@@ -331,13 +530,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getModalInstance() {
|
// Selection state helpers for the picker modal.
|
||||||
if (!window.pulseModal) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return window.pulseModal.getOrCreate(addSlideModal);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setCardSelected(card, selected) {
|
function setCardSelected(card, selected) {
|
||||||
if (!card) {
|
if (!card) {
|
||||||
return;
|
return;
|
||||||
@@ -370,50 +563,13 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPickerThumb(slide) {
|
// Picker card rendering.
|
||||||
function createPlaceholder() {
|
|
||||||
var placeholder = document.createElement('div');
|
|
||||||
var icon = document.createElement('i');
|
|
||||||
var label = document.createElement('span');
|
|
||||||
|
|
||||||
placeholder.className = 'playlist-slide-picker-thumb-placeholder';
|
|
||||||
icon.className = 'bi bi-image playlist-slide-picker-thumb-placeholder-icon';
|
|
||||||
icon.setAttribute('aria-hidden', 'true');
|
|
||||||
label.className = 'playlist-slide-picker-thumb-placeholder-label';
|
|
||||||
label.textContent = 'No thumbnail';
|
|
||||||
|
|
||||||
placeholder.appendChild(icon);
|
|
||||||
placeholder.appendChild(label);
|
|
||||||
return placeholder;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (slide && slide.thumbnail_path) {
|
|
||||||
var image = document.createElement('img');
|
|
||||||
image.className = 'playlist-slide-picker-thumb-image';
|
|
||||||
image.loading = 'lazy';
|
|
||||||
image.alt = String(slide.title || 'Slide');
|
|
||||||
image.src = String(slide.thumbnail_path || '');
|
|
||||||
image.addEventListener('error', function () {
|
|
||||||
if (image.parentNode) {
|
|
||||||
image.parentNode.replaceChild(createPlaceholder(), image);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return image;
|
|
||||||
}
|
|
||||||
|
|
||||||
return createPlaceholder();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createPickerCard(slide) {
|
function createPickerCard(slide) {
|
||||||
var button = document.createElement('button');
|
var button = document.createElement('button');
|
||||||
var media = document.createElement('div');
|
|
||||||
var title = document.createElement('div');
|
|
||||||
var check = document.createElement('span');
|
|
||||||
var badge = document.createElement('span');
|
|
||||||
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
var searchText = String(slide && slide.title ? slide.title : '').toLowerCase();
|
||||||
|
|
||||||
button.type = 'button';
|
button.type = 'button';
|
||||||
button.className = 'playlist-slide-picker-card';
|
button.className = PICKER_CARD_CLASS;
|
||||||
button.setAttribute('aria-pressed', 'false');
|
button.setAttribute('aria-pressed', 'false');
|
||||||
button.setAttribute('data-slide-id', String(slide.id || ''));
|
button.setAttribute('data-slide-id', String(slide.id || ''));
|
||||||
button.setAttribute('data-search-text', searchText);
|
button.setAttribute('data-search-text', searchText);
|
||||||
@@ -424,18 +580,9 @@
|
|||||||
button.classList.add('is-assigned');
|
button.classList.add('is-assigned');
|
||||||
}
|
}
|
||||||
|
|
||||||
media.className = 'playlist-slide-picker-media';
|
var media = buildSlidePickerMedia(slide);
|
||||||
media.appendChild(createPickerThumb(slide));
|
var title = document.createElement('div');
|
||||||
check.className = 'playlist-slide-picker-check bi bi-check2-circle';
|
title.className = PICKER_TITLE_CLASS;
|
||||||
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');
|
title.textContent = String(slide && slide.title ? slide.title : 'Slide');
|
||||||
|
|
||||||
button.appendChild(media);
|
button.appendChild(media);
|
||||||
@@ -444,6 +591,7 @@
|
|||||||
return button;
|
return button;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Picker filtering and modal controls.
|
||||||
function setShowAssignedState(showAssignedSlides) {
|
function setShowAssignedState(showAssignedSlides) {
|
||||||
if (!addSlideShowAssigned) {
|
if (!addSlideShowAssigned) {
|
||||||
return;
|
return;
|
||||||
@@ -489,11 +637,7 @@
|
|||||||
});
|
});
|
||||||
|
|
||||||
if (addSlideEmpty) {
|
if (addSlideEmpty) {
|
||||||
if (!showAssignedSlides && assignedCount && visibleCount === 0) {
|
addSlideEmpty.textContent = getSlidePickerEmptyMessage(query, showAssignedSlides, assignedCount, visibleCount);
|
||||||
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);
|
addSlideEmpty.classList.toggle('is-hidden', visibleCount !== 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -547,6 +691,11 @@
|
|||||||
canvas_width: slide.canvas_width,
|
canvas_width: slide.canvas_width,
|
||||||
canvas_height: slide.canvas_height,
|
canvas_height: slide.canvas_height,
|
||||||
canvas_signature: String(slide.canvasSignature || ''),
|
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',
|
title: slide.title || 'Slide',
|
||||||
duration_seconds: 10,
|
duration_seconds: 10,
|
||||||
schedule_mode: 'always',
|
schedule_mode: 'always',
|
||||||
@@ -607,24 +756,19 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function scheduleSummaryForRow(row) {
|
function scheduleSummaryForRow(row) {
|
||||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
var fields = getScheduleRowFields(row);
|
||||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
var mode = String(fields.mode ? fields.mode.value : 'always');
|
||||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
var days = formatDays(fields.daysJson && fields.daysJson.value ? fields.daysJson.value : '[]');
|
||||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
|
||||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
|
||||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
|
||||||
var mode = String(modeInput ? modeInput.value : 'always');
|
|
||||||
var days = formatDays(daysJson && daysJson.value ? daysJson.value : '[]');
|
|
||||||
|
|
||||||
if (mode === 'dates') {
|
if (mode === 'dates') {
|
||||||
if (startDatetime && endDatetime && startDatetime.value && endDatetime.value) {
|
if (fields.startDatetime && fields.endDatetime && fields.startDatetime.value && fields.endDatetime.value) {
|
||||||
return 'Dates: ' + startDatetime.value.replace('T', ' ') + ' to ' + endDatetime.value.replace('T', ' ');
|
return 'Dates: ' + fields.startDatetime.value.replace('T', ' ') + ' to ' + fields.endDatetime.value.replace('T', ' ');
|
||||||
}
|
}
|
||||||
return 'Dates: not set';
|
return 'Dates: not set';
|
||||||
}
|
}
|
||||||
if (mode === 'times') {
|
if (mode === 'times') {
|
||||||
if (days && startTime && endTime && startTime.value && endTime.value) {
|
if (days && fields.startTime && fields.endTime && fields.startTime.value && fields.endTime.value) {
|
||||||
return 'Times: ' + days + ' ' + startTime.value.slice(0, 5) + '-' + endTime.value.slice(0, 5);
|
return 'Times: ' + days + ' ' + fields.startTime.value.slice(0, 5) + '-' + fields.endTime.value.slice(0, 5);
|
||||||
}
|
}
|
||||||
return 'Times: not set';
|
return 'Times: not set';
|
||||||
}
|
}
|
||||||
@@ -662,14 +806,7 @@
|
|||||||
|
|
||||||
slide.isAssigned = isAssigned;
|
slide.isAssigned = isAssigned;
|
||||||
|
|
||||||
if (card) {
|
updateSlidePickerCardState(card, isAssigned, isCanvasMismatch);
|
||||||
card.setAttribute('data-is-assigned', isAssigned ? 'true' : 'false');
|
|
||||||
card.disabled = isAssigned || isCanvasMismatch;
|
|
||||||
card.classList.toggle('is-assigned', isAssigned);
|
|
||||||
card.classList.toggle('is-mismatch', isCanvasMismatch);
|
|
||||||
card.classList.toggle('is-hidden', false);
|
|
||||||
card.setAttribute('aria-disabled', card.disabled ? 'true' : 'false');
|
|
||||||
}
|
|
||||||
if (isAssigned) {
|
if (isAssigned) {
|
||||||
slidePickerSelection.delete(slideId);
|
slidePickerSelection.delete(slideId);
|
||||||
if (card) {
|
if (card) {
|
||||||
@@ -737,31 +874,26 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setRowSchedule(row, values) {
|
function setRowSchedule(row, values) {
|
||||||
var modeInput = row.querySelector('[name="schedule_mode[]"]');
|
var fields = getScheduleRowFields(row);
|
||||||
var startDatetime = row.querySelector('[name="schedule_start_datetime[]"]');
|
|
||||||
var endDatetime = row.querySelector('[name="schedule_end_datetime[]"]');
|
|
||||||
var startTime = row.querySelector('[name="schedule_start_time[]"]');
|
|
||||||
var endTime = row.querySelector('[name="schedule_end_time[]"]');
|
|
||||||
var daysJson = row.querySelector('[name="schedule_days_json[]"]');
|
|
||||||
var summary = row.querySelector('.playlist-schedule-summary');
|
var summary = row.querySelector('.playlist-schedule-summary');
|
||||||
|
|
||||||
if (modeInput) {
|
if (fields.mode) {
|
||||||
modeInput.value = values.schedule_mode || 'always';
|
fields.mode.value = values.schedule_mode || 'always';
|
||||||
}
|
}
|
||||||
if (startDatetime) {
|
if (fields.startDatetime) {
|
||||||
startDatetime.value = values.schedule_start_datetime || '';
|
fields.startDatetime.value = values.schedule_start_datetime || '';
|
||||||
}
|
}
|
||||||
if (endDatetime) {
|
if (fields.endDatetime) {
|
||||||
endDatetime.value = values.schedule_end_datetime || '';
|
fields.endDatetime.value = values.schedule_end_datetime || '';
|
||||||
}
|
}
|
||||||
if (startTime) {
|
if (fields.startTime) {
|
||||||
startTime.value = values.schedule_start_time || '';
|
fields.startTime.value = values.schedule_start_time || '';
|
||||||
}
|
}
|
||||||
if (endTime) {
|
if (fields.endTime) {
|
||||||
endTime.value = values.schedule_end_time || '';
|
fields.endTime.value = values.schedule_end_time || '';
|
||||||
}
|
}
|
||||||
if (daysJson) {
|
if (fields.daysJson) {
|
||||||
daysJson.value = values.schedule_days_json || '[]';
|
fields.daysJson.value = values.schedule_days_json || '[]';
|
||||||
}
|
}
|
||||||
if (summary) {
|
if (summary) {
|
||||||
summary.textContent = values.summary || scheduleSummaryForRow(row);
|
summary.textContent = values.summary || scheduleSummaryForRow(row);
|
||||||
@@ -786,40 +918,217 @@
|
|||||||
row.setAttribute('data-row-key', rowKey);
|
row.setAttribute('data-row-key', rowKey);
|
||||||
row.setAttribute('data-slide-id', String(values.slide_id));
|
row.setAttribute('data-slide-id', String(values.slide_id));
|
||||||
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
|
row.setAttribute('data-canvas-signature', String(values.canvas_signature || ''));
|
||||||
|
row.setAttribute('data-media-type', String(values.media_type || ''));
|
||||||
|
row.setAttribute('data-media-path', String(values.media_path || ''));
|
||||||
|
row.setAttribute('data-video-source-path', String(values.videoSourcePath || values.media_path || ''));
|
||||||
|
row.setAttribute('data-video-duration-seconds', String(values.videoDurationSeconds || ''));
|
||||||
|
row.setAttribute('data-use-video-duration', String(Boolean(values.useVideoDuration)));
|
||||||
|
var durationActionMarkup = buildVideoDurationButtonMarkup(
|
||||||
|
Boolean(values.useVideoDuration),
|
||||||
|
Boolean(values.showVideoDurationButton) || String(values.media_type || '').trim() === 'video'
|
||||||
|
);
|
||||||
row.innerHTML = '' +
|
row.innerHTML = '' +
|
||||||
'<td class="playlist-order-cell" data-label="Order">' +
|
buildPlaylistOrderCellMarkup() +
|
||||||
'<div class="playlist-order-cell-inner">' +
|
buildPlaylistSlideCellMarkup(values, thumbnailStyle, thumbnailMarkup) +
|
||||||
'<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder"><span class="playlist-drag-handle-icon" aria-hidden="true"><svg class="playlist-drag-handle-svg" viewBox="0 0 24 32" focusable="false" aria-hidden="true"><polygon points="12,2 20,9 4,9"></polygon><rect x="4" y="14" width="16" height="4" rx="2"></rect><polygon points="4,23 20,23 12,30"></polygon></svg></span></button>' +
|
buildPlaylistScheduleCellMarkup(values) +
|
||||||
'<span class="playlist-order-number"></span>' +
|
buildPlaylistDurationCellMarkup(values, durationActionMarkup) +
|
||||||
'</div>' +
|
buildPlaylistActionsCellMarkup(playlistId, rowKey);
|
||||||
'</td>' +
|
|
||||||
'<td data-label="Slide">' +
|
|
||||||
'<div class="playlist-slide-cell">' +
|
|
||||||
'<div class="playlist-slide-thumb" aria-hidden="true"' + thumbnailStyle + '>' + thumbnailMarkup + '</div>' +
|
|
||||||
'<div class="playlist-slide-cell-content"><span class="playlist-slide-title">' + values.title + '</span></div>' +
|
|
||||||
'</div>' +
|
|
||||||
'<input type="hidden" name="slide_id[]" value="' + values.slide_id + '" form="playlist-edit-form" /></td>' +
|
|
||||||
'<td>' +
|
|
||||||
'<div class="playlist-schedule-summary">' + values.summary + '</div>' +
|
|
||||||
'<input type="hidden" name="schedule_mode[]" value="' + values.schedule_mode + '" form="playlist-edit-form" />' +
|
|
||||||
'<input type="hidden" name="schedule_start_datetime[]" value="' + values.schedule_start_datetime + '" form="playlist-edit-form" />' +
|
|
||||||
'<input type="hidden" name="schedule_end_datetime[]" value="' + values.schedule_end_datetime + '" form="playlist-edit-form" />' +
|
|
||||||
'<input type="hidden" name="schedule_start_time[]" value="' + values.schedule_start_time + '" form="playlist-edit-form" />' +
|
|
||||||
'<input type="hidden" name="schedule_end_time[]" value="' + values.schedule_end_time + '" form="playlist-edit-form" />' +
|
|
||||||
'<input type="hidden" name="schedule_days_json[]" value="' + values.schedule_days_json + '" form="playlist-edit-form" />' +
|
|
||||||
'</td>' +
|
|
||||||
'<td><input name="duration_seconds[]" type="number" min="1" value="' + values.duration_seconds + '" required form="playlist-edit-form" class="form-control form-control-sm playlist-duration-input text-end" /></td>' +
|
|
||||||
'<td><div class="actions playlist-item-actions">' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-primary" data-schedule-config="/playlists/' + encodeURIComponent(playlistId) + '/slides/0/config" data-schedule-config-row="' + rowKey + '">Schedule</button>' +
|
|
||||||
'<button type="button" class="btn btn-sm btn-danger" data-playlist-remove-row>Remove</button>' +
|
|
||||||
'</div></td>';
|
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function loadVideoDuration(mediaPath) {
|
||||||
|
var cacheKey = String(mediaPath || '').trim();
|
||||||
|
var cachedPromise;
|
||||||
|
|
||||||
|
if (!cacheKey) {
|
||||||
|
return Promise.reject(new Error('No video source is available for this slide.'));
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedPromise = videoDurationCache[cacheKey];
|
||||||
|
if (cachedPromise) {
|
||||||
|
return cachedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
cachedPromise = new Promise(function (resolve, reject) {
|
||||||
|
var video = document.createElement('video');
|
||||||
|
var timeoutId = window.setTimeout(function () {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('Timed out loading the video metadata.'));
|
||||||
|
}, 15000);
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
video.removeAttribute('src');
|
||||||
|
video.load();
|
||||||
|
if (video.parentNode) {
|
||||||
|
video.parentNode.removeChild(video);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
video.preload = 'metadata';
|
||||||
|
video.muted = true;
|
||||||
|
video.playsInline = true;
|
||||||
|
video.setAttribute('playsinline', '');
|
||||||
|
video.setAttribute('muted', '');
|
||||||
|
video.style.position = 'absolute';
|
||||||
|
video.style.left = '-9999px';
|
||||||
|
video.style.top = '0';
|
||||||
|
video.style.width = '1px';
|
||||||
|
video.style.height = '1px';
|
||||||
|
video.style.opacity = '0';
|
||||||
|
video.style.pointerEvents = 'none';
|
||||||
|
|
||||||
|
video.addEventListener('loadedmetadata', function () {
|
||||||
|
var duration = Number(video.duration);
|
||||||
|
cleanup();
|
||||||
|
if (Number.isFinite(duration) && duration > 0) {
|
||||||
|
resolve(Math.round(duration * 1000) / 1000);
|
||||||
|
} else {
|
||||||
|
reject(new Error('The video duration could not be read.'));
|
||||||
|
}
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
video.addEventListener('error', function () {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('The video duration could not be read.'));
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
video.addEventListener('abort', function () {
|
||||||
|
cleanup();
|
||||||
|
reject(new Error('The video duration could not be read.'));
|
||||||
|
}, { once: true });
|
||||||
|
|
||||||
|
document.body.appendChild(video);
|
||||||
|
video.src = cacheKey;
|
||||||
|
video.load();
|
||||||
|
}).catch(function (error) {
|
||||||
|
delete videoDurationCache[cacheKey];
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
|
||||||
|
videoDurationCache[cacheKey] = cachedPromise;
|
||||||
|
return cachedPromise;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setVideoDurationToggleState(row, button, isPressed) {
|
||||||
|
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
|
||||||
|
var flag = row ? row.querySelector('[name="use_video_duration[]"]') : null;
|
||||||
|
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
|
||||||
|
|
||||||
|
if (row && input && isPressed && !row.getAttribute('data-video-duration-previous-value')) {
|
||||||
|
row.setAttribute('data-video-duration-previous-value', String(input.value || ''));
|
||||||
|
}
|
||||||
|
|
||||||
|
syncVideoDurationButtonState(button, isPressed);
|
||||||
|
|
||||||
|
if (input) {
|
||||||
|
if (!isPressed && row) {
|
||||||
|
var previousValue = String(row.getAttribute('data-video-duration-previous-value') || '').trim();
|
||||||
|
if (previousValue) {
|
||||||
|
input.value = previousValue;
|
||||||
|
}
|
||||||
|
row.removeAttribute('data-video-duration-previous-value');
|
||||||
|
}
|
||||||
|
input.disabled = Boolean(isPressed);
|
||||||
|
input.classList.toggle('playlist-duration-input-locked', Boolean(isPressed));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (flag) {
|
||||||
|
flag.value = isPressed ? '1' : '0';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (row) {
|
||||||
|
row.setAttribute('data-use-video-duration', isPressed ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!row || !input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPressed) {
|
||||||
|
if (!mirror) {
|
||||||
|
mirror = document.createElement('input');
|
||||||
|
mirror.type = 'hidden';
|
||||||
|
mirror.setAttribute('data-video-duration-mirror', '');
|
||||||
|
input.parentNode.insertBefore(mirror, input.nextSibling);
|
||||||
|
}
|
||||||
|
mirror.name = input.name;
|
||||||
|
mirror.value = input.value;
|
||||||
|
if (input.getAttribute('form')) {
|
||||||
|
mirror.setAttribute('form', input.getAttribute('form'));
|
||||||
|
}
|
||||||
|
} else if (mirror && mirror.parentNode) {
|
||||||
|
mirror.parentNode.removeChild(mirror);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function applyVideoDurationToRow(row, button) {
|
||||||
|
var mediaType = String(row && row.getAttribute('data-media-type') || '').trim().toLowerCase();
|
||||||
|
var mediaPath = String(row && (row.getAttribute('data-video-source-path') || row.getAttribute('data-media-path')) || '').trim();
|
||||||
|
var input = row ? row.querySelector('[name="duration_seconds[]"]') : null;
|
||||||
|
var isVideoRow = mediaType === 'video' || Boolean(mediaPath);
|
||||||
|
var storedDuration = Number(row && row.getAttribute('data-video-duration-seconds') || 0);
|
||||||
|
|
||||||
|
if (!isVideoRow || !input) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button && button.getAttribute('aria-pressed') === 'true') {
|
||||||
|
setVideoDurationToggleState(row, button, false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setVideoDurationToggleState(row, button, true);
|
||||||
|
|
||||||
|
if (Number.isFinite(storedDuration) && storedDuration > 0) {
|
||||||
|
input.value = String(storedDuration);
|
||||||
|
var existingMirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
|
||||||
|
if (existingMirror) {
|
||||||
|
existingMirror.value = input.value;
|
||||||
|
}
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button) {
|
||||||
|
button.disabled = true;
|
||||||
|
button.textContent = VIDEO_DURATION_LOADING_LABEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
var durationSeconds = await loadVideoDuration(mediaPath);
|
||||||
|
if (row) {
|
||||||
|
row.setAttribute('data-video-duration-seconds', String(durationSeconds));
|
||||||
|
}
|
||||||
|
input.value = String(durationSeconds);
|
||||||
|
var mirror = row ? row.querySelector('[data-video-duration-mirror]') : null;
|
||||||
|
if (mirror) {
|
||||||
|
mirror.value = input.value;
|
||||||
|
}
|
||||||
|
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||||||
|
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||||||
|
} catch (error) {
|
||||||
|
setVideoDurationToggleState(row, button, false);
|
||||||
|
alert(error && error.message ? error.message : 'Unable to read the video duration.');
|
||||||
|
} finally {
|
||||||
|
if (button) {
|
||||||
|
button.disabled = false;
|
||||||
|
syncVideoDurationButtonState(button, button.getAttribute('aria-pressed') === 'true');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
tbody.addEventListener('click', function (event) {
|
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 removeButton = event.target.closest('[data-playlist-remove-row]');
|
||||||
var scheduleButton = event.target.closest('[data-schedule-config]');
|
var scheduleButton = event.target.closest('[data-schedule-config]');
|
||||||
var row = event.target.closest('tr[data-playlist-slide-row]');
|
var durationButton = event.target.closest('[data-use-video-duration]');
|
||||||
|
var row = getPlaylistSlideRow(event.target);
|
||||||
|
|
||||||
if (!row) {
|
if (!row) {
|
||||||
return;
|
return;
|
||||||
@@ -835,8 +1144,92 @@
|
|||||||
if (scheduleButton) {
|
if (scheduleButton) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (typeof window.openScheduleModal === 'function') {
|
if (typeof window.openScheduleModal === 'function') {
|
||||||
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(row.getAttribute('data-row-key') || ''));
|
window.openScheduleModal(scheduleButton.getAttribute('data-schedule-config') + '?row_key=' + encodeURIComponent(getPlaylistSlideRowKey(row)));
|
||||||
}
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationButton) {
|
||||||
|
var rowKey = getPlaylistSlideRowKey(row);
|
||||||
|
var pressedOnInput = lastDurationPointerDown && lastDurationPointerDown.type === 'input' && lastDurationPointerDown.rowKey === rowKey;
|
||||||
|
var releasedOnButton = lastDurationPointerUp && lastDurationPointerUp.type === 'button' && lastDurationPointerUp.rowKey === rowKey;
|
||||||
|
|
||||||
|
if (event.detail > 0 && (pressedOnInput || !releasedOnButton)) {
|
||||||
|
lastDurationPointerDown = null;
|
||||||
|
lastDurationPointerUp = null;
|
||||||
|
event.preventDefault();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault();
|
||||||
|
void applyVideoDurationToRow(row, durationButton);
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDurationPointerDown = null;
|
||||||
|
lastDurationPointerUp = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener('mousedown', function (event) {
|
||||||
|
var row = getPlaylistSlideRow(event.target);
|
||||||
|
var dragHandle = event.target && event.target.closest ? event.target.closest('[data-playlist-drag-handle]') : null;
|
||||||
|
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
||||||
|
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
||||||
|
|
||||||
|
if (row && !dragHandle) {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationInput) {
|
||||||
|
lastDurationPointerDown = getDurationPointerState(event.target, 'input');
|
||||||
|
event.stopPropagation();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationToggle) {
|
||||||
|
lastDurationPointerDown = getDurationPointerState(durationToggle, 'button');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDurationPointerDown = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener('mouseup', function (event) {
|
||||||
|
var durationInput = event.target && event.target.matches('[name="duration_seconds[]"]') ? event.target : null;
|
||||||
|
var durationToggle = isDurationControl(event.target) ? event.target.closest('.playlist-use-video-duration') : null;
|
||||||
|
var row = getPlaylistSlideRow(event.target);
|
||||||
|
|
||||||
|
if (durationInput) {
|
||||||
|
lastDurationPointerUp = getDurationPointerState(row, 'input');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (durationToggle) {
|
||||||
|
lastDurationPointerUp = getDurationPointerState(row, 'button');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
lastDurationPointerUp = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
tbody.addEventListener('input', function (event) {
|
||||||
|
if (event.target && event.target.matches('[name="duration_seconds[]"]')) {
|
||||||
|
var editedRow = getPlaylistSlideRow(event.target);
|
||||||
|
var editedButton = editedRow ? editedRow.querySelector('.playlist-use-video-duration') : null;
|
||||||
|
var editedMirror = editedRow ? editedRow.querySelector('[data-video-duration-mirror]') : null;
|
||||||
|
var useVideoDuration = Boolean(editedButton && editedButton.getAttribute('aria-pressed') === 'true');
|
||||||
|
|
||||||
|
if (editedRow) {
|
||||||
|
editedRow.setAttribute('data-use-video-duration', useVideoDuration ? 'true' : 'false');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (useVideoDuration && editedMirror) {
|
||||||
|
editedMirror.value = event.target.value;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (editedMirror && !useVideoDuration) {
|
||||||
|
editedMirror.parentNode.removeChild(editedMirror);
|
||||||
|
}
|
||||||
|
markPlaylistDirty();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -60,6 +60,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
var submitting = false;
|
var submitting = false;
|
||||||
var uploadMaxBytes = 100 * 1024 * 1024;
|
var uploadMaxBytes = 100 * 1024 * 1024;
|
||||||
var uploadMaxLabel = '100 MB';
|
var uploadMaxLabel = '100 MB';
|
||||||
|
var uploadVideoMaxBytes = 1024 * 1024 * 1024;
|
||||||
|
var uploadVideoMaxLabel = '1 GB';
|
||||||
|
var videoDurationCache = Object.create(null);
|
||||||
var previewRenderFrame = 0;
|
var previewRenderFrame = 0;
|
||||||
var previewPopupWindow = null;
|
var previewPopupWindow = null;
|
||||||
var previewPopupRenderFrame = 0;
|
var previewPopupRenderFrame = 0;
|
||||||
@@ -255,6 +258,12 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
return current && current.value !== undefined ? current.value : '';
|
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) {
|
function normalizeEditorData(value) {
|
||||||
return String(value || '');
|
return String(value || '');
|
||||||
}
|
}
|
||||||
@@ -458,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>';
|
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() {
|
function destroyEditors() {
|
||||||
editorInstances.forEach(function (editor) {
|
editorInstances.forEach(function (editor) {
|
||||||
if (editor && typeof editor.destroy === 'function') {
|
if (editor && typeof editor.destroy === 'function') {
|
||||||
@@ -767,7 +817,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearImagePreviewUrls() {
|
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) {
|
if (input.dataset.previewUrl) {
|
||||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
delete input.dataset.previewUrl;
|
delete input.dataset.previewUrl;
|
||||||
@@ -775,14 +825,41 @@ 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) {
|
function getRegionUploadZone(input) {
|
||||||
return input ? input.closest('[data-region-upload-zone]') : null;
|
return input ? input.closest('[data-region-upload-zone]') : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function getRegionUploadHiddenValue(input) {
|
function getRegionUploadHiddenValue(input) {
|
||||||
var regionId = input && input.name ? input.name.replace(/^region_image_/, '') : '';
|
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 card = regionId ? templateFields.querySelector('[data-region-id="' + regionId + '"]') : null;
|
||||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||||
return hidden ? hidden.value : '';
|
return hidden ? hidden.value : '';
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -847,21 +924,42 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
return Boolean(file) && Number(file.size || 0) <= uploadMaxBytes;
|
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) {
|
function rejectOversizeRegionImage(input, file) {
|
||||||
|
return rejectOversizeRegionMedia(input, file, 'image');
|
||||||
|
}
|
||||||
|
|
||||||
|
function rejectOversizeRegionMedia(input, file, mediaType) {
|
||||||
var zone = getRegionUploadZone(input);
|
var zone = getRegionUploadZone(input);
|
||||||
var hiddenValue = getRegionUploadHiddenValue(input);
|
var hiddenValue = getRegionUploadHiddenValue(input);
|
||||||
|
var limitLabel = getRegionMediaUploadLimitLabel(mediaType || getRegionMediaTypeFromInput(input));
|
||||||
|
|
||||||
if (typeof window.showToast === 'function') {
|
if (typeof window.showToast === 'function') {
|
||||||
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
|
window.showToast('File must be ' + limitLabel + ' or smaller.', 'warning');
|
||||||
} else {
|
} else {
|
||||||
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
|
window.alert('File must be ' + limitLabel + ' or smaller.');
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (input.dataset.previewUrl) {
|
if (input.dataset.previewUrl) {
|
||||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
delete input.dataset.previewUrl;
|
delete input.dataset.previewUrl;
|
||||||
}
|
}
|
||||||
renderImageRegionPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
|
renderRegionMediaPreview(zone ? zone.closest('[data-region-id]') : null, hiddenValue);
|
||||||
if (file) {
|
if (file) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
@@ -870,12 +968,18 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function setRegionImageInputFile(input, file) {
|
function setRegionImageInputFile(input, file) {
|
||||||
|
return setRegionMediaInputFile(input, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function setRegionMediaInputFile(input, file) {
|
||||||
if (!input || !file) {
|
if (!input || !file) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRegionImageFileWithinLimit(file)) {
|
var mediaType = getRegionMediaTypeFromInput(input);
|
||||||
rejectOversizeRegionImage(input, file);
|
|
||||||
|
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||||
|
rejectOversizeRegionMedia(input, file, mediaType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -886,18 +990,24 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function uploadRegionImageFile(input, file) {
|
function uploadRegionImageFile(input, file) {
|
||||||
|
return uploadRegionMediaFile(input, file);
|
||||||
|
}
|
||||||
|
|
||||||
|
function uploadRegionMediaFile(input, file) {
|
||||||
if (!input || !file) {
|
if (!input || !file) {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRegionImageFileWithinLimit(file)) {
|
var mediaType = getRegionMediaTypeFromInput(input);
|
||||||
rejectOversizeRegionImage(input, file);
|
|
||||||
|
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||||
|
rejectOversizeRegionMedia(input, file, mediaType);
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
var regionId = input.name.replace(/^region_image_/, '');
|
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
|
||||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||||
var zone = getRegionUploadZone(input);
|
var zone = getRegionUploadZone(input);
|
||||||
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
|
var uploadToken = String(Date.now()) + ':' + Math.random().toString(16).slice(2);
|
||||||
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
|
var previousUploadedPath = String(input.dataset.uploadedPath || '').trim();
|
||||||
@@ -945,7 +1055,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
var error = new Error(response.responseText || 'Unable to upload image.');
|
var error = new Error(response.responseText || 'Unable to upload media.');
|
||||||
error.status = response.status;
|
error.status = response.status;
|
||||||
error.responseText = response.responseText;
|
error.responseText = response.responseText;
|
||||||
reject(error);
|
reject(error);
|
||||||
@@ -961,7 +1071,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
|
|
||||||
var uploadedPath = String(payload.path || '').trim();
|
var uploadedPath = String(payload.path || '').trim();
|
||||||
if (!uploadedPath) {
|
if (!uploadedPath) {
|
||||||
reject(new Error('Unable to upload image.'));
|
reject(new Error('Unable to upload media.'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -977,7 +1087,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
input.dataset.uploadedNeedsCleanup = '1';
|
input.dataset.uploadedNeedsCleanup = '1';
|
||||||
input.value = '';
|
input.value = '';
|
||||||
input.setCustomValidity('');
|
input.setCustomValidity('');
|
||||||
renderImageRegionPreview(card, uploadedPath);
|
renderRegionMediaPreview(card, uploadedPath);
|
||||||
|
void syncVideoRegionDuration(card, uploadedPath);
|
||||||
templateSelectorLock.markEdited();
|
templateSelectorLock.markEdited();
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
|
if (previousNeedsCleanup && previousUploadedPath && previousUploadedPath !== uploadedPath) {
|
||||||
@@ -993,7 +1104,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(new Error('Unable to upload image.'));
|
reject(new Error('Unable to upload media.'));
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onabort = function () {
|
xhr.onabort = function () {
|
||||||
@@ -1002,7 +1113,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
reject(new Error('Unable to upload image.'));
|
reject(new Error('Unable to upload media.'));
|
||||||
};
|
};
|
||||||
|
|
||||||
xhr.onloadend = function () {
|
xhr.onloadend = function () {
|
||||||
@@ -1023,7 +1134,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getPendingUploadCleanupPaths() {
|
function getPendingUploadCleanupPaths() {
|
||||||
return getRegionImageInputs().map(function (input) {
|
return getRegionMediaInputs().map(function (input) {
|
||||||
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
if (!input || String(input.dataset.uploadedNeedsCleanup || '') !== '1') {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -1033,7 +1144,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
}
|
}
|
||||||
|
|
||||||
function clearPendingUploadCleanupPaths() {
|
function clearPendingUploadCleanupPaths() {
|
||||||
getRegionImageInputs().forEach(function (input) {
|
getRegionMediaInputs().forEach(function (input) {
|
||||||
if (!input) {
|
if (!input) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1077,7 +1188,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderImageRegionPreview(card, value) {
|
function renderRegionMediaPreview(card, value) {
|
||||||
if (!card) {
|
if (!card) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1087,13 +1198,148 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var mediaType = getRegionMediaType(card);
|
||||||
|
var isVideo = mediaType === 'video';
|
||||||
|
|
||||||
previewBox.innerHTML = value
|
previewBox.innerHTML = value
|
||||||
? '<img class="slide-image-region-preview" src="' + escapeHtml(value) + '" alt="Current image preview" />'
|
? '<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') + '">' +
|
||||||
: '<div class="slide-image-region-preview slide-image-region-preview-empty">No image</div>';
|
(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() {
|
function getRegionImageInputs() {
|
||||||
return Array.prototype.slice.call(templateFields.querySelectorAll('input[type="file"][name^="region_image_"]'));
|
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() {
|
function hasPendingRegionImageUpload() {
|
||||||
@@ -1102,6 +1348,10 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasPendingRegionMediaUpload() {
|
||||||
|
return hasPendingRegionImageUpload();
|
||||||
|
}
|
||||||
|
|
||||||
function getRegionBox(region, canvasWidth, canvasHeight) {
|
function getRegionBox(region, canvasWidth, canvasHeight) {
|
||||||
return {
|
return {
|
||||||
left: Math.max(0, Math.round(Number(region.x || 0))),
|
left: Math.max(0, Math.round(Number(region.x || 0))),
|
||||||
@@ -1177,6 +1427,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (popupOverlay) {
|
if (popupOverlay) {
|
||||||
popupOverlay.innerHTML = '';
|
popupOverlay.innerHTML = '';
|
||||||
}
|
}
|
||||||
|
startPreviewVideoPlayback(popupCanvas);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1198,6 +1449,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
popupEmpty.style.display = 'none';
|
popupEmpty.style.display = 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startPreviewVideoPlayback(popupCanvas);
|
||||||
|
|
||||||
var stageRect = popupStage.getBoundingClientRect();
|
var stageRect = popupStage.getBoundingClientRect();
|
||||||
if (!stageRect.width || !stageRect.height) {
|
if (!stageRect.width || !stageRect.height) {
|
||||||
if (previewPopupRenderFrame) {
|
if (previewPopupRenderFrame) {
|
||||||
@@ -1349,6 +1602,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]');
|
var htmlInput = card && card.querySelector('textarea[name="region_html_' + region.id + '"]');
|
||||||
var imageHidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + region.id + '"]');
|
var 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 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 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 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 + '"]');
|
var disableAudioInput = card && card.querySelector('input[type="checkbox"][name="region_disable_audio_' + region.id + '"]');
|
||||||
@@ -1361,6 +1616,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
var existingApi = existingContent[region.region_key] || {};
|
var existingApi = existingContent[region.region_key] || {};
|
||||||
var value = region.region_type === 'image'
|
var value = region.region_type === 'image'
|
||||||
? ((imageFileInput && imageFileInput.dataset.previewUrl) ? imageFileInput.dataset.previewUrl : (imageHidden ? imageHidden.value : ''))
|
? ((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'
|
: region.region_type === 'webpage'
|
||||||
? (webpageInput ? webpageInput.value : '')
|
? (webpageInput ? webpageInput.value : '')
|
||||||
: region.region_type === 'rtmp'
|
: region.region_type === 'rtmp'
|
||||||
@@ -1383,6 +1640,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
: getTextStyleFromCard(card, region);
|
: getTextStyleFromCard(card, region);
|
||||||
var content = region.region_type === 'image'
|
var content = region.region_type === 'image'
|
||||||
? (value ? '<img class="slide-preview-image" src="' + escapeHtml(value) + '" alt="" />' : '<div class="slide-preview-placeholder">Image</div>')
|
? (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'
|
: region.region_type === 'webpage'
|
||||||
? renderPreviewWebpageRegion(value)
|
? renderPreviewWebpageRegion(value)
|
||||||
: region.region_type === 'rtmp'
|
: region.region_type === 'rtmp'
|
||||||
@@ -1407,6 +1666,8 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
: renderPreviewTextRegion(region, value, style, scale);
|
: renderPreviewTextRegion(region, value, style, scale);
|
||||||
var selected = region.region_type === 'image'
|
var selected = region.region_type === 'image'
|
||||||
? ' slide-preview-image-region'
|
? ' slide-preview-image-region'
|
||||||
|
: region.region_type === 'video'
|
||||||
|
? ' slide-preview-video-region'
|
||||||
: region.region_type === 'webpage'
|
: region.region_type === 'webpage'
|
||||||
? ' slide-preview-webpage-region'
|
? ' slide-preview-webpage-region'
|
||||||
: region.region_type === 'rtmp'
|
: region.region_type === 'rtmp'
|
||||||
@@ -1421,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>';
|
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('');
|
}).join('');
|
||||||
|
|
||||||
|
startPreviewVideoPlayback(slidePreviewOverlay);
|
||||||
|
|
||||||
syncPopupPreview();
|
syncPopupPreview();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1610,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 regionHeight = Math.max(1, Math.round(Number(region && region.height ? region.height : 0) || 1));
|
||||||
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
|
var regionRatio = reduceAspectRatio(regionWidth, regionHeight);
|
||||||
return '' +
|
return '' +
|
||||||
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
|
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '" data-region-media-type="image">' +
|
||||||
'<div class="card-header template-field-head">' +
|
'<div class="card-header template-field-head">' +
|
||||||
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
'<strong>' + escapeHtml(region.label) + '</strong>' +
|
||||||
'<span class="chip">Image</span>' +
|
'<span class="chip">Image</span>' +
|
||||||
@@ -1652,6 +1915,53 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
'</div>';
|
'</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() {
|
function renderTemplate() {
|
||||||
templateSelectorLock.reset();
|
templateSelectorLock.reset();
|
||||||
clearImagePreviewUrls();
|
clearImagePreviewUrls();
|
||||||
@@ -1667,6 +1977,9 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (region.region_type === 'image') {
|
if (region.region_type === 'image') {
|
||||||
return renderImageRegion(region);
|
return renderImageRegion(region);
|
||||||
}
|
}
|
||||||
|
if (region.region_type === 'video') {
|
||||||
|
return renderVideoRegion(region);
|
||||||
|
}
|
||||||
if (region.region_type === 'webpage') {
|
if (region.region_type === 'webpage') {
|
||||||
return renderWebpageRegion(region);
|
return renderWebpageRegion(region);
|
||||||
}
|
}
|
||||||
@@ -1685,6 +1998,14 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
return renderTextRegion(region);
|
return renderTextRegion(region);
|
||||||
}).join('') : '<div class="muted">This template has no regions.</div>';
|
}).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) {
|
templateFields.querySelectorAll('.ckeditor-holder').forEach(function (holder) {
|
||||||
var regionId = holder.getAttribute('data-region-id');
|
var regionId = holder.getAttribute('data-region-id');
|
||||||
var source = holder.querySelector('.ckeditor-source');
|
var source = holder.querySelector('.ckeditor-source');
|
||||||
@@ -1792,25 +2113,26 @@ 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 () {
|
input.addEventListener('change', function () {
|
||||||
var regionId = input.name.replace(/^region_image_/, '');
|
var mediaType = getRegionMediaTypeFromInput(input);
|
||||||
|
var regionId = input.name.replace(/^region_(?:image|video)_/, '');
|
||||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
var hidden = card && card.querySelector('input[type="hidden"][name="' + getRegionExistingMediaPrefix(mediaType) + regionId + '"]');
|
||||||
var file = input.files && input.files.length ? input.files[0] : null;
|
var file = input.files && input.files.length ? input.files[0] : null;
|
||||||
|
|
||||||
if (file && !isRegionImageFileWithinLimit(file)) {
|
if (file && !isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||||
if (input.dataset.previewUrl) {
|
if (input.dataset.previewUrl) {
|
||||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
delete input.dataset.previewUrl;
|
delete input.dataset.previewUrl;
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (typeof window.showToast === 'function') {
|
if (typeof window.showToast === 'function') {
|
||||||
window.showToast('File must be ' + uploadMaxLabel + ' or smaller.', 'warning');
|
window.showToast('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.', 'warning');
|
||||||
} else {
|
} else {
|
||||||
window.alert('File must be ' + uploadMaxLabel + ' or smaller.');
|
window.alert('File must be ' + getRegionMediaUploadLimitLabel(mediaType) + ' or smaller.');
|
||||||
}
|
}
|
||||||
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
renderRegionMediaPreview(card, hidden ? hidden.value : '');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1821,22 +2143,24 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (file) {
|
if (file) {
|
||||||
input.dataset.previewUrl = URL.createObjectURL(file);
|
input.dataset.previewUrl = URL.createObjectURL(file);
|
||||||
}
|
}
|
||||||
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();
|
templateSelectorLock.markEdited();
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
if (file) {
|
if (file) {
|
||||||
uploadRegionImageFile(input, file).catch(function (error) {
|
uploadRegionMediaFile(input, file).catch(function (error) {
|
||||||
if (input.dataset.previewUrl) {
|
if (input.dataset.previewUrl) {
|
||||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
URL.revokeObjectURL(input.dataset.previewUrl);
|
||||||
delete input.dataset.previewUrl;
|
delete input.dataset.previewUrl;
|
||||||
}
|
}
|
||||||
input.value = '';
|
input.value = '';
|
||||||
if (typeof showToast === 'function') {
|
if (typeof showToast === 'function') {
|
||||||
showToast(error && error.message ? error.message : 'Unable to upload image.', 'danger');
|
showToast(error && error.message ? error.message : 'Unable to upload media.', 'danger');
|
||||||
} else {
|
} else {
|
||||||
window.alert(error && error.message ? error.message : 'Unable to upload image.');
|
window.alert(error && error.message ? error.message : 'Unable to upload media.');
|
||||||
}
|
}
|
||||||
renderImageRegionPreview(card, hidden ? hidden.value : '');
|
renderRegionMediaPreview(card, hidden ? hidden.value : '');
|
||||||
|
void syncVideoRegionDuration(card, hidden ? hidden.value : '');
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -1844,7 +2168,7 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
});
|
});
|
||||||
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
|
templateFields.querySelectorAll('[data-region-upload-zone]').forEach(function (zone) {
|
||||||
var regionId = zone.getAttribute('data-region-upload-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) {
|
if (!input) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1864,24 +2188,27 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
|
|
||||||
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
|
var files = event.dataTransfer && event.dataTransfer.files ? event.dataTransfer.files : null;
|
||||||
var file = files && files.length ? files[0] : 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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!isRegionImageFileWithinLimit(file)) {
|
if (!isRegionMediaFileWithinLimit(file, mediaType)) {
|
||||||
rejectOversizeRegionImage(input, file);
|
rejectOversizeRegionMedia(input, file, mediaType);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setRegionImageInputFile(input, file);
|
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 () {
|
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 card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
||||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
var mediaType = getRegionMediaType(card);
|
||||||
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
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() : '';
|
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
||||||
|
|
||||||
if (input && input.dataset.previewUrl) {
|
if (input && input.dataset.previewUrl) {
|
||||||
@@ -1899,48 +2226,16 @@ import { createTemplateSelectorLockController } from '/assets/js/slides/slide-fo
|
|||||||
if (hidden) {
|
if (hidden) {
|
||||||
hidden.value = '';
|
hidden.value = '';
|
||||||
}
|
}
|
||||||
|
var durationHidden = card ? getRegionVideoDurationHiddenInput(regionId) : null;
|
||||||
|
if (durationHidden) {
|
||||||
|
durationHidden.value = '';
|
||||||
|
}
|
||||||
|
|
||||||
renderImageRegionPreview(card, '');
|
renderRegionMediaPreview(card, '');
|
||||||
templateSelectorLock.markEdited();
|
templateSelectorLock.markEdited();
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
templateFields.querySelectorAll('.slide-image-region-preview-shell').forEach(function (shell) {
|
|
||||||
shell.addEventListener('click', function () {
|
|
||||||
var regionId = shell.getAttribute('data-remove-region-image');
|
|
||||||
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
|
|
||||||
var hidden = card && card.querySelector('input[type="hidden"][name="existing_region_image_' + regionId + '"]');
|
|
||||||
var input = card && card.querySelector('input[type="file"][name="region_image_' + regionId + '"]');
|
|
||||||
var uploadPath = input ? String(input.dataset.uploadedPath || '').trim() : '';
|
|
||||||
|
|
||||||
if (input && input.dataset.previewUrl) {
|
|
||||||
URL.revokeObjectURL(input.dataset.previewUrl);
|
|
||||||
delete input.dataset.previewUrl;
|
|
||||||
}
|
|
||||||
if (input && input.dataset.uploadedNeedsCleanup === '1' && uploadPath) {
|
|
||||||
queueUploadCleanup([uploadPath]);
|
|
||||||
}
|
|
||||||
if (input) {
|
|
||||||
input.value = '';
|
|
||||||
delete input.dataset.uploadedPath;
|
|
||||||
delete input.dataset.uploadedNeedsCleanup;
|
|
||||||
}
|
|
||||||
if (hidden) {
|
|
||||||
hidden.value = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
renderImageRegionPreview(card, '');
|
|
||||||
templateSelectorLock.markEdited();
|
|
||||||
requestPreviewRender();
|
|
||||||
});
|
|
||||||
|
|
||||||
shell.addEventListener('keydown', function (event) {
|
|
||||||
if (event.key === 'Enter' || event.key === ' ') {
|
|
||||||
event.preventDefault();
|
|
||||||
shell.click();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
templateFields.querySelectorAll('select[name^="region_rss_feed_id_"], input[name^="region_rss_item_number_"], select[name^="region_api_source_id_"], input[name^="region_api_item_number_"]').forEach(function (input) {
|
||||||
input.addEventListener('input', function () {
|
input.addEventListener('input', function () {
|
||||||
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
|
var regionId = input.name.replace(/^region_(?:rss_feed_id|rss_item_number|api_source_id|api_item_number)_/, '');
|
||||||
|
|||||||
@@ -1,606 +0,0 @@
|
|||||||
(function () {
|
|
||||||
var dataElement = document.getElementById('template-editor-data');
|
|
||||||
if (!dataElement) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var utils = window.templateDesignerUtils || {};
|
|
||||||
|
|
||||||
var templateData = {};
|
|
||||||
try {
|
|
||||||
templateData = JSON.parse(dataElement.getAttribute('data-json') || dataElement.textContent || '{}') || {};
|
|
||||||
} catch (_error) {
|
|
||||||
templateData = {};
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingRegions = Array.isArray(templateData.regions)
|
|
||||||
? templateData.regions
|
|
||||||
: (Array.isArray(templateData) ? templateData : []);
|
|
||||||
var stage = document.getElementById('designer-stage');
|
|
||||||
var overlay = document.getElementById('designer-overlay');
|
|
||||||
var regionList = document.getElementById('region-list');
|
|
||||||
var regionSelect = document.getElementById('region-select');
|
|
||||||
var canvasSizeSelect = document.getElementById('canvas-size-select');
|
|
||||||
var canvasSizeIdInput = document.getElementById('canvas-size-id');
|
|
||||||
var canvasSizeSummary = document.getElementById('canvas-size-summary');
|
|
||||||
var canvasWidthInput = document.getElementById('canvas-width');
|
|
||||||
var canvasHeightInput = document.getElementById('canvas-height');
|
|
||||||
var backgroundInput = document.getElementById('background-image');
|
|
||||||
var backgroundColorInput = document.getElementById('background-color');
|
|
||||||
var backgroundPreview = document.getElementById('background-preview');
|
|
||||||
var backgroundEmpty = document.getElementById('background-empty');
|
|
||||||
var removeBackgroundButton = document.getElementById('remove-background-image');
|
|
||||||
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
|
|
||||||
var addRegionButton = document.getElementById('add-region-button');
|
|
||||||
var regionAddModal = document.getElementById('region-add-modal');
|
|
||||||
var regionCardTemplate = document.getElementById('region-card-template');
|
|
||||||
var regionsJsonInput = document.getElementById('regions-json');
|
|
||||||
var templateForm = document.getElementById('template-form');
|
|
||||||
var draft = null;
|
|
||||||
var selectedIndex = -1;
|
|
||||||
var overlayRenderFrame = 0;
|
|
||||||
|
|
||||||
function escapeHtml(value) {
|
|
||||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function valueOrDefault(value, fallback) {
|
|
||||||
return utils.valueOrDefault ? utils.valueOrDefault(value, fallback) : (value === undefined || value === null || value === '' ? fallback : value);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCanvasSize() {
|
|
||||||
return {
|
|
||||||
width: Math.max(1, Number(canvasWidthInput.value || 1920)),
|
|
||||||
height: Math.max(1, Number(canvasHeightInput.value || 1080))
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clamp(value, min, max) {
|
|
||||||
return utils.clamp ? utils.clamp(value, min, max) : Math.max(min, Math.min(max, value));
|
|
||||||
}
|
|
||||||
|
|
||||||
function getCards() {
|
|
||||||
return Array.prototype.slice.call(regionList.querySelectorAll('.region-item'));
|
|
||||||
}
|
|
||||||
|
|
||||||
function cardAt(index) {
|
|
||||||
return getCards()[index] || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRegionName(card) {
|
|
||||||
return utils.getRegionName ? utils.getRegionName(card) : String(card.querySelector('[name="region_name[]"]').value || '').trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncRegionIdentity(card, value) {
|
|
||||||
if (utils.syncRegionIdentity) {
|
|
||||||
utils.syncRegionIdentity(card, value);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
var next = String(value || '').trim();
|
|
||||||
card.querySelector('[name="region_name[]"]').value = next;
|
|
||||||
card.querySelector('[name="region_key[]"]').value = next;
|
|
||||||
card.querySelector('[name="region_label[]"]').value = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
function validateRegionNames() {
|
|
||||||
var cards = getCards();
|
|
||||||
var names = {};
|
|
||||||
var hasDuplicate = false;
|
|
||||||
|
|
||||||
cards.forEach(function (card) {
|
|
||||||
var input = card.querySelector('[name="region_name[]"]');
|
|
||||||
if (!input) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
var normalized = String(input.value || '').trim().toLowerCase();
|
|
||||||
if (!normalized) {
|
|
||||||
input.setCustomValidity('Region name is required.');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!names[normalized]) {
|
|
||||||
names[normalized] = [];
|
|
||||||
}
|
|
||||||
names[normalized].push(input);
|
|
||||||
});
|
|
||||||
|
|
||||||
Object.keys(names).forEach(function (key) {
|
|
||||||
var inputs = names[key];
|
|
||||||
if (inputs.length > 1) {
|
|
||||||
hasDuplicate = true;
|
|
||||||
inputs.forEach(function (input) {
|
|
||||||
input.setCustomValidity('Region names must be unique on this template.');
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
inputs[0].setCustomValidity('');
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return !hasDuplicate;
|
|
||||||
}
|
|
||||||
|
|
||||||
function readCard(card) {
|
|
||||||
return utils.readCard ? utils.readCard(card) : {
|
|
||||||
region_key: getRegionName(card),
|
|
||||||
label: getRegionName(card),
|
|
||||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
|
||||||
font_family: card.querySelector('[name="font_family[]"]').value,
|
|
||||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
|
||||||
y: Number(card.querySelector('[name="region_y[]"]').value || 0),
|
|
||||||
width: Number(card.querySelector('[name="region_width[]"]').value || 0),
|
|
||||||
height: Number(card.querySelector('[name="region_height[]"]').value || 0),
|
|
||||||
z_index: Number(card.querySelector('[name="region_z[]"]').value || 0)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function writeCard(card, values) {
|
|
||||||
if (utils.writeCard) {
|
|
||||||
utils.writeCard(card, values);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (values.region_name !== undefined) {
|
|
||||||
syncRegionIdentity(card, values.region_name);
|
|
||||||
} else if (values.region_key !== undefined) {
|
|
||||||
syncRegionIdentity(card, values.region_key);
|
|
||||||
} else if (values.label !== undefined) {
|
|
||||||
syncRegionIdentity(card, values.label);
|
|
||||||
}
|
|
||||||
if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; }
|
|
||||||
if (values.font_family !== undefined) { card.querySelector('[name="font_family[]"]').value = values.font_family; }
|
|
||||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
|
||||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
|
||||||
if (values.width !== undefined) { card.querySelector('[name="region_width[]"]').value = Math.round(values.width); }
|
|
||||||
if (values.height !== undefined) { card.querySelector('[name="region_height[]"]').value = Math.round(values.height); }
|
|
||||||
if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.round(values.z_index); }
|
|
||||||
}
|
|
||||||
|
|
||||||
function getOverlayRect() {
|
|
||||||
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
|
|
||||||
}
|
|
||||||
|
|
||||||
function toCanvasPoint(event) {
|
|
||||||
return utils.toCanvasPoint ? utils.toCanvasPoint(event, overlay, getCanvasSize()) : {
|
|
||||||
x: 0,
|
|
||||||
y: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function canvasRectToPixels(region) {
|
|
||||||
return utils.canvasRectToPixels ? utils.canvasRectToPixels(region, overlay, getCanvasSize()) : {
|
|
||||||
left: 0,
|
|
||||||
top: 0,
|
|
||||||
width: 0,
|
|
||||||
height: 0
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateAspectRatio() {
|
|
||||||
var size = getCanvasSize();
|
|
||||||
stage.style.aspectRatio = size.width + ' / ' + size.height;
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCanvasSizeSummary() {
|
|
||||||
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
|
|
||||||
canvasSizeSummary.textContent = option ? option.textContent : '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateCanvasSizeLock() {
|
|
||||||
var lockOnExistingTemplate = canvasSizeSelect.dataset.lockOnExistingTemplate === 'true';
|
|
||||||
var locked = lockOnExistingTemplate && getCards().length > 0;
|
|
||||||
canvasSizeSelect.disabled = locked;
|
|
||||||
canvasSizeSummary.classList.toggle('is-locked', locked);
|
|
||||||
}
|
|
||||||
|
|
||||||
function syncCanvasSizeSelection() {
|
|
||||||
var option = canvasSizeSelect.options[canvasSizeSelect.selectedIndex];
|
|
||||||
if (!option) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (canvasSizeIdInput) {
|
|
||||||
canvasSizeIdInput.value = option.value;
|
|
||||||
}
|
|
||||||
canvasWidthInput.value = Math.max(1, Number(option.dataset.width || canvasWidthInput.value || 1920));
|
|
||||||
canvasHeightInput.value = Math.max(1, Number(option.dataset.height || canvasHeightInput.value || 1080));
|
|
||||||
updateAspectRatio();
|
|
||||||
updateCanvasSizeSummary();
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateBackgroundPreview(file) {
|
|
||||||
if (!file) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (removeBackgroundFlag) {
|
|
||||||
removeBackgroundFlag.checked = false;
|
|
||||||
}
|
|
||||||
var reader = new FileReader();
|
|
||||||
reader.onload = function () {
|
|
||||||
backgroundPreview.src = reader.result;
|
|
||||||
backgroundPreview.style.display = 'block';
|
|
||||||
backgroundEmpty.style.display = 'none';
|
|
||||||
};
|
|
||||||
reader.readAsDataURL(file);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateStageBackgroundColor() {
|
|
||||||
if (!stage) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
stage.style.backgroundColor = backgroundColorInput && backgroundColorInput.value ? backgroundColorInput.value : '#111111';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getRegionChipLabel(regionType) {
|
|
||||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rss' ? 'RSS' : 'Text';
|
|
||||||
}
|
|
||||||
|
|
||||||
function populateRegionCard(card, region) {
|
|
||||||
var chip = card.querySelector('[data-region-chip]');
|
|
||||||
var title = card.querySelector('[data-region-title]');
|
|
||||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
|
||||||
var fontFamilyInput = card.querySelector('[name="font_family[]"]');
|
|
||||||
var regionTypeInput = card.querySelector('[name="region_type[]"]');
|
|
||||||
var regionKeyInput = card.querySelector('[name="region_key[]"]');
|
|
||||||
var regionLabelInput = card.querySelector('[name="region_label[]"]');
|
|
||||||
|
|
||||||
if (title) {
|
|
||||||
title.textContent = region.label || region.region_key || 'Region';
|
|
||||||
}
|
|
||||||
if (chip) {
|
|
||||||
chip.textContent = getRegionChipLabel(region.region_type);
|
|
||||||
}
|
|
||||||
if (nameInput) {
|
|
||||||
nameInput.value = region.region_key || region.label || '';
|
|
||||||
}
|
|
||||||
if (fontFamilyInput) {
|
|
||||||
fontFamilyInput.value = region.region_type === 'image' ? '' : (region.font_family || 'Arial');
|
|
||||||
}
|
|
||||||
if (regionTypeInput) {
|
|
||||||
regionTypeInput.value = region.region_type || 'text';
|
|
||||||
}
|
|
||||||
if (regionKeyInput) {
|
|
||||||
regionKeyInput.value = region.region_key || region.label || '';
|
|
||||||
}
|
|
||||||
if (regionLabelInput) {
|
|
||||||
regionLabelInput.value = region.label || region.region_key || '';
|
|
||||||
}
|
|
||||||
card.querySelector('[name="region_x[]"]').value = valueOrDefault(region.x, 80);
|
|
||||||
card.querySelector('[name="region_y[]"]').value = valueOrDefault(region.y, 80);
|
|
||||||
card.querySelector('[name="region_z[]"]').value = valueOrDefault(region.z_index, 1);
|
|
||||||
card.querySelector('[name="region_width[]"]').value = valueOrDefault(region.width, 300);
|
|
||||||
card.querySelector('[name="region_height[]"]').value = valueOrDefault(region.height, 120);
|
|
||||||
}
|
|
||||||
|
|
||||||
function updateRegionLabel(card) {
|
|
||||||
var label = getRegionName(card) || 'Region';
|
|
||||||
var cards = getCards();
|
|
||||||
var index = cards.indexOf(card);
|
|
||||||
var title = card.querySelector('.template-field-head strong');
|
|
||||||
if (title) {
|
|
||||||
title.textContent = label;
|
|
||||||
}
|
|
||||||
if (index >= 0 && regionSelect.options[index]) {
|
|
||||||
regionSelect.options[index].textContent = label;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeRegionCard(region) {
|
|
||||||
var card;
|
|
||||||
if (regionCardTemplate && regionCardTemplate.content) {
|
|
||||||
card = regionCardTemplate.content.firstElementChild.cloneNode(true);
|
|
||||||
} else {
|
|
||||||
card = document.createElement('div');
|
|
||||||
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
|
||||||
}
|
|
||||||
populateRegionCard(card, region);
|
|
||||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
|
||||||
nameInput.addEventListener('input', function () {
|
|
||||||
syncRegionIdentity(card, nameInput.value);
|
|
||||||
updateRegionLabel(card);
|
|
||||||
validateRegionNames();
|
|
||||||
renderRegionSidebar();
|
|
||||||
renderOverlay();
|
|
||||||
});
|
|
||||||
card.addEventListener('click', function (event) {
|
|
||||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
setSelected(getCards().indexOf(card));
|
|
||||||
});
|
|
||||||
card.querySelector('.remove-region').addEventListener('click', function (event) {
|
|
||||||
event.preventDefault();
|
|
||||||
card.remove();
|
|
||||||
if (!getCards().length) {
|
|
||||||
selectedIndex = -1;
|
|
||||||
} else if (selectedIndex >= getCards().length) {
|
|
||||||
selectedIndex = getCards().length - 1;
|
|
||||||
}
|
|
||||||
renderRegionSidebar();
|
|
||||||
renderOverlay();
|
|
||||||
});
|
|
||||||
return card;
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRegionList(initialRegions) {
|
|
||||||
regionList.innerHTML = '';
|
|
||||||
initialRegions.forEach(function (region) {
|
|
||||||
regionList.appendChild(makeRegionCard(region));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderRegionSidebar() {
|
|
||||||
var cards = getCards();
|
|
||||||
regionSelect.innerHTML = '';
|
|
||||||
if (!cards.length) {
|
|
||||||
regionSelect.disabled = true;
|
|
||||||
regionList.innerHTML = '';
|
|
||||||
updateCanvasSizeLock();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
regionSelect.disabled = false;
|
|
||||||
if (selectedIndex < 0 || selectedIndex >= cards.length) {
|
|
||||||
selectedIndex = 0;
|
|
||||||
}
|
|
||||||
cards.forEach(function (card, index) {
|
|
||||||
var option = document.createElement('option');
|
|
||||||
option.value = String(index);
|
|
||||||
option.textContent = getRegionName(card) || ('Region ' + (index + 1));
|
|
||||||
if (index === selectedIndex) {
|
|
||||||
option.selected = true;
|
|
||||||
}
|
|
||||||
regionSelect.appendChild(option);
|
|
||||||
card.hidden = index !== selectedIndex;
|
|
||||||
});
|
|
||||||
regionSelect.value = String(selectedIndex);
|
|
||||||
updateCanvasSizeLock();
|
|
||||||
validateRegionNames();
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderOverlay() {
|
|
||||||
var cards = getCards();
|
|
||||||
var selectedCard = selectedIndex >= 0 ? cards[selectedIndex] : null;
|
|
||||||
var selectedNow = selectedCard ? cards.indexOf(selectedCard) : -1;
|
|
||||||
var regions = cards.map(readCard);
|
|
||||||
regionsJsonInput.value = JSON.stringify(regions);
|
|
||||||
overlay.innerHTML = regions.map(function (region, index) {
|
|
||||||
var box = canvasRectToPixels(region);
|
|
||||||
var selected = index === selectedNow ? ' selected' : '';
|
|
||||||
return '<div class="designer-rect' + selected + '" data-index="' + index + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;"><div class="designer-rect-label">' + escapeHtml(region.label || region.region_key || 'Region') + '</div><span class="resize-handle nw" data-dir="nw"></span><span class="resize-handle ne" data-dir="ne"></span><span class="resize-handle sw" data-dir="sw"></span><span class="resize-handle se" data-dir="se"></span></div>';
|
|
||||||
}).join('');
|
|
||||||
if (draft) {
|
|
||||||
var rect = getOverlayRect();
|
|
||||||
var size = getCanvasSize();
|
|
||||||
var draftBox = { x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: Math.abs(draft.end.x - draft.start.x), height: Math.abs(draft.end.y - draft.start.y) };
|
|
||||||
overlay.innerHTML += '<div class="designer-rect designer-draft" style="left:' + ((draftBox.x / size.width) * rect.width) + 'px;top:' + ((draftBox.y / size.height) * rect.height) + 'px;width:' + ((draftBox.width / size.width) * rect.width) + 'px;height:' + ((draftBox.height / size.height) * rect.height) + 'px;"></div>';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function requestOverlayRender() {
|
|
||||||
if (overlayRenderFrame) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
overlayRenderFrame = window.requestAnimationFrame(function () {
|
|
||||||
overlayRenderFrame = 0;
|
|
||||||
renderOverlay();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function render() {
|
|
||||||
updateAspectRatio();
|
|
||||||
renderRegionSidebar();
|
|
||||||
renderOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function setSelected(index) {
|
|
||||||
var cards = getCards();
|
|
||||||
if (!cards.length) {
|
|
||||||
selectedIndex = -1;
|
|
||||||
} else if (index < 0) {
|
|
||||||
selectedIndex = 0;
|
|
||||||
} else {
|
|
||||||
selectedIndex = clamp(index, 0, cards.length - 1);
|
|
||||||
}
|
|
||||||
renderRegionSidebar();
|
|
||||||
renderOverlay();
|
|
||||||
}
|
|
||||||
|
|
||||||
function addRegion(region) {
|
|
||||||
var hint = regionList.querySelector('.muted');
|
|
||||||
if (hint) {
|
|
||||||
hint.remove();
|
|
||||||
}
|
|
||||||
regionList.appendChild(makeRegionCard(region));
|
|
||||||
setSelected(getCards().length - 1);
|
|
||||||
}
|
|
||||||
|
|
||||||
function openAddRegionModal() {
|
|
||||||
if (!regionAddModal || !window.bootstrap || !window.bootstrap.Modal) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).show();
|
|
||||||
}
|
|
||||||
|
|
||||||
function createDefaultRegion(type) {
|
|
||||||
var count = getCards().length + 1;
|
|
||||||
var name = 'region_' + count;
|
|
||||||
return {
|
|
||||||
region_key: name,
|
|
||||||
label: name,
|
|
||||||
region_type: type,
|
|
||||||
font_family: type === 'text' || type === 'html' ? 'Arial' : '',
|
|
||||||
x: 80,
|
|
||||||
y: 80,
|
|
||||||
width: type === 'image' || type === 'webpage' || type === 'html' ? 420 : 300,
|
|
||||||
height: type === 'image' || type === 'webpage' || type === 'html' ? 240 : 120,
|
|
||||||
z_index: 1
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function clampRegion(region) {
|
|
||||||
var size = getCanvasSize();
|
|
||||||
var minSize = 12;
|
|
||||||
var x = clamp(region.x, 0, size.width - minSize);
|
|
||||||
var y = clamp(region.y, 0, size.height - minSize);
|
|
||||||
var width = Math.max(minSize, region.width);
|
|
||||||
var height = Math.max(minSize, region.height);
|
|
||||||
if (x + width > size.width) {
|
|
||||||
width = size.width - x;
|
|
||||||
}
|
|
||||||
if (y + height > size.height) {
|
|
||||||
height = size.height - y;
|
|
||||||
}
|
|
||||||
return { x: Math.round(x), y: Math.round(y), width: Math.round(Math.max(minSize, width)), height: Math.round(Math.max(minSize, height)) };
|
|
||||||
}
|
|
||||||
|
|
||||||
function startDraw(event) {
|
|
||||||
var start = toCanvasPoint(event);
|
|
||||||
draft = { start: start, end: start };
|
|
||||||
renderOverlay();
|
|
||||||
function moveHandler(moveEvent) {
|
|
||||||
draft.end = toCanvasPoint(moveEvent);
|
|
||||||
requestOverlayRender();
|
|
||||||
}
|
|
||||||
function upHandler(upEvent) {
|
|
||||||
draft.end = toCanvasPoint(upEvent);
|
|
||||||
var width = Math.abs(draft.end.x - draft.start.x);
|
|
||||||
var height = Math.abs(draft.end.y - draft.start.y);
|
|
||||||
if (width >= 8 && height >= 8) {
|
|
||||||
var region = clampRegion({ x: Math.min(draft.start.x, draft.end.x), y: Math.min(draft.start.y, draft.end.y), width: width, height: height });
|
|
||||||
addRegion({ region_key: 'region_' + (getCards().length + 1), label: 'Region ' + (getCards().length + 1), region_type: 'text', x: region.x, y: region.y, width: region.width, height: region.height, z_index: 1 });
|
|
||||||
}
|
|
||||||
draft = null;
|
|
||||||
requestOverlayRender();
|
|
||||||
document.removeEventListener('mousemove', moveHandler);
|
|
||||||
document.removeEventListener('mouseup', upHandler);
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', moveHandler);
|
|
||||||
document.addEventListener('mouseup', upHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function startMove(index, event) {
|
|
||||||
var startPoint = toCanvasPoint(event);
|
|
||||||
var startRegion = readCard(cardAt(index));
|
|
||||||
function moveHandler(moveEvent) {
|
|
||||||
var currentPoint = toCanvasPoint(moveEvent);
|
|
||||||
var dx = currentPoint.x - startPoint.x;
|
|
||||||
var dy = currentPoint.y - startPoint.y;
|
|
||||||
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
|
|
||||||
writeCard(cardAt(index), { x: next.x, y: next.y });
|
|
||||||
requestOverlayRender();
|
|
||||||
}
|
|
||||||
function upHandler() {
|
|
||||||
document.removeEventListener('mousemove', moveHandler);
|
|
||||||
document.removeEventListener('mouseup', upHandler);
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', moveHandler);
|
|
||||||
document.addEventListener('mouseup', upHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
function resizeFromHandle(index, dir, event) {
|
|
||||||
var startPoint = toCanvasPoint(event);
|
|
||||||
var startRegion = readCard(cardAt(index));
|
|
||||||
function moveHandler(moveEvent) {
|
|
||||||
var currentPoint = toCanvasPoint(moveEvent);
|
|
||||||
var dx = currentPoint.x - startPoint.x;
|
|
||||||
var dy = currentPoint.y - startPoint.y;
|
|
||||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
|
||||||
if (dir.indexOf('w') !== -1) { next.x = startRegion.x + dx; next.width = startRegion.width - dx; }
|
|
||||||
if (dir.indexOf('e') !== -1) { next.width = startRegion.width + dx; }
|
|
||||||
if (dir.indexOf('n') !== -1) { next.y = startRegion.y + dy; next.height = startRegion.height - dy; }
|
|
||||||
if (dir.indexOf('s') !== -1) { next.height = startRegion.height + dy; }
|
|
||||||
if (next.width < 12) { if (dir.indexOf('w') !== -1) { next.x -= 12 - next.width; } next.width = 12; }
|
|
||||||
if (next.height < 12) { if (dir.indexOf('n') !== -1) { next.y -= 12 - next.height; } next.height = 12; }
|
|
||||||
next = clampRegion(next);
|
|
||||||
writeCard(cardAt(index), { x: next.x, y: next.y, width: next.width, height: next.height });
|
|
||||||
requestOverlayRender();
|
|
||||||
}
|
|
||||||
function upHandler() {
|
|
||||||
document.removeEventListener('mousemove', moveHandler);
|
|
||||||
document.removeEventListener('mouseup', upHandler);
|
|
||||||
}
|
|
||||||
document.addEventListener('mousemove', moveHandler);
|
|
||||||
document.addEventListener('mouseup', upHandler);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (addRegionButton && regionAddModal) {
|
|
||||||
var addRegionTypeButtons = regionAddModal.querySelectorAll('[data-add-region-type]');
|
|
||||||
addRegionButton.addEventListener('click', function () {
|
|
||||||
openAddRegionModal();
|
|
||||||
});
|
|
||||||
Array.prototype.forEach.call(addRegionTypeButtons, function (button) {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
var regionType = button.getAttribute('data-add-region-type');
|
|
||||||
addRegion(createDefaultRegion(regionType));
|
|
||||||
if (window.bootstrap && window.bootstrap.Modal) {
|
|
||||||
window.bootstrap.Modal.getOrCreateInstance(regionAddModal).hide();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
backgroundInput.addEventListener('change', function () {
|
|
||||||
var file = backgroundInput.files && backgroundInput.files[0];
|
|
||||||
if (file) {
|
|
||||||
updateBackgroundPreview(file);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
if (removeBackgroundButton && removeBackgroundFlag) {
|
|
||||||
removeBackgroundButton.addEventListener('click', function () {
|
|
||||||
removeBackgroundFlag.checked = true;
|
|
||||||
backgroundInput.value = '';
|
|
||||||
backgroundPreview.removeAttribute('src');
|
|
||||||
backgroundPreview.style.display = 'none';
|
|
||||||
backgroundEmpty.style.display = 'block';
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (backgroundColorInput) {
|
|
||||||
backgroundColorInput.addEventListener('input', updateStageBackgroundColor);
|
|
||||||
}
|
|
||||||
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
|
|
||||||
canvasWidthInput.addEventListener('input', render);
|
|
||||||
canvasHeightInput.addEventListener('input', render);
|
|
||||||
regionSelect.addEventListener('change', function () { setSelected(Number(regionSelect.value || 0)); });
|
|
||||||
overlay.addEventListener('mousedown', function (event) {
|
|
||||||
var rect = event.target.closest('.designer-rect');
|
|
||||||
if (rect) {
|
|
||||||
var index = Number(rect.getAttribute('data-index'));
|
|
||||||
var handle = event.target.closest('.resize-handle');
|
|
||||||
event.preventDefault();
|
|
||||||
setSelected(index);
|
|
||||||
if (handle) {
|
|
||||||
resizeFromHandle(index, handle.getAttribute('data-dir'), event);
|
|
||||||
} else {
|
|
||||||
startMove(index, event);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (event.target !== overlay && !event.target.classList.contains('designer-overlay')) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
event.preventDefault();
|
|
||||||
setSelected(-1);
|
|
||||||
startDraw(event);
|
|
||||||
});
|
|
||||||
if (templateForm) {
|
|
||||||
templateForm.addEventListener('formdata', function (event) {
|
|
||||||
if (!validateRegionNames()) {
|
|
||||||
event.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
syncCanvasSizeSelection();
|
|
||||||
event.formData.set('regions_json', JSON.stringify(getCards().map(readCard)));
|
|
||||||
});
|
|
||||||
|
|
||||||
templateForm.addEventListener('submit', function () {
|
|
||||||
if (!validateRegionNames()) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
syncCanvasSizeSelection();
|
|
||||||
regionsJsonInput.value = JSON.stringify(getCards().map(readCard));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
renderRegionList(existingRegions);
|
|
||||||
syncCanvasSizeSelection();
|
|
||||||
updateStageBackgroundColor();
|
|
||||||
render();
|
|
||||||
})();
|
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
|
|
||||||
function getDefaultRegionSize(regionType, lockRatio) {
|
function getDefaultRegionSize(regionType, lockRatio) {
|
||||||
var ratio = parseLockRatio(lockRatio);
|
var ratio = parseLockRatio(lockRatio);
|
||||||
var locked = regionType === 'image' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
|
var locked = regionType === 'image' || regionType === 'video' || regionType === 'webpage' || regionType === 'html' || regionType === 'rtmp' || regionType === 'rss' || regionType === 'api';
|
||||||
|
|
||||||
if (ratio) {
|
if (ratio) {
|
||||||
if (ratio.ratio >= 1) {
|
if (ratio.ratio >= 1) {
|
||||||
|
|||||||
@@ -36,10 +36,26 @@
|
|||||||
var regionCardTemplate = document.getElementById('region-card-template');
|
var regionCardTemplate = document.getElementById('region-card-template');
|
||||||
var regionsJsonInput = document.getElementById('regions-json');
|
var regionsJsonInput = document.getElementById('regions-json');
|
||||||
var templateForm = document.getElementById('template-form');
|
var templateForm = document.getElementById('template-form');
|
||||||
|
var templateRegionUsageElement = document.getElementById('template-region-usage');
|
||||||
|
var regionUsageSet = new Set();
|
||||||
var draft = null;
|
var draft = null;
|
||||||
var selectedIndex = -1;
|
var selectedIndex = -1;
|
||||||
var overlayRenderFrame = 0;
|
var overlayRenderFrame = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
var regionUsageData = JSON.parse((templateRegionUsageElement && templateRegionUsageElement.textContent) || '[]');
|
||||||
|
if (Array.isArray(regionUsageData)) {
|
||||||
|
regionUsageData.forEach(function (regionKey) {
|
||||||
|
var normalized = String(regionKey || '').trim();
|
||||||
|
if (normalized) {
|
||||||
|
regionUsageSet.add(normalized);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (_error) {
|
||||||
|
regionUsageSet = new Set();
|
||||||
|
}
|
||||||
|
|
||||||
function escapeHtml(value) {
|
function escapeHtml(value) {
|
||||||
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
return utils.escapeHtml ? utils.escapeHtml(value) : String(value === undefined || value === null ? '' : value);
|
||||||
}
|
}
|
||||||
@@ -312,7 +328,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRegionChipLabel(regionType) {
|
function getRegionChipLabel(regionType) {
|
||||||
return regionType === 'image' ? 'Image' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
return regionType === 'image' ? 'Image' : regionType === 'video' ? 'Video' : regionType === 'webpage' ? 'Webpage' : regionType === 'html' ? 'HTML' : regionType === 'rtmp' ? 'RTMP' : regionType === 'rss' ? 'RSS' : 'Text';
|
||||||
}
|
}
|
||||||
|
|
||||||
function populateRegionCard(card, region) {
|
function populateRegionCard(card, region) {
|
||||||
@@ -334,7 +350,7 @@
|
|||||||
nameInput.value = region.region_key || region.label || '';
|
nameInput.value = region.region_key || region.label || '';
|
||||||
}
|
}
|
||||||
if (fontFamilyInput) {
|
if (fontFamilyInput) {
|
||||||
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
fontFamilyInput.value = region.region_type === 'image' || region.region_type === 'video' || region.region_type === 'rtmp' ? '' : (region.font_family || 'Arial');
|
||||||
}
|
}
|
||||||
if (regionTypeInput) {
|
if (regionTypeInput) {
|
||||||
regionTypeInput.value = region.region_type || 'text';
|
regionTypeInput.value = region.region_type || 'text';
|
||||||
@@ -357,6 +373,21 @@
|
|||||||
updateRegionLockBadge(card);
|
updateRegionLockBadge(card);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setRegionRemovalState(card) {
|
||||||
|
var removeButton = card.querySelector('[data-region-remove-button]');
|
||||||
|
if (!removeButton) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var regionKey = String(card.dataset.regionKey || getRegionName(card) || '').trim();
|
||||||
|
var regionDeletionLocked = regionUsageSet.has(regionKey);
|
||||||
|
removeButton.disabled = regionDeletionLocked;
|
||||||
|
if (regionDeletionLocked) {
|
||||||
|
removeButton.title = 'Regions cannot be deleted while this template is used by slides.';
|
||||||
|
} else {
|
||||||
|
removeButton.removeAttribute('title');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function updateRegionLabel(card) {
|
function updateRegionLabel(card) {
|
||||||
var label = getRegionName(card) || 'Region';
|
var label = getRegionName(card) || 'Region';
|
||||||
var cards = getCards();
|
var cards = getCards();
|
||||||
@@ -379,6 +410,8 @@
|
|||||||
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
card.className = 'card card-outline card-secondary admin-form-card region-item mb-3';
|
||||||
}
|
}
|
||||||
populateRegionCard(card, region);
|
populateRegionCard(card, region);
|
||||||
|
card.dataset.regionKey = String(region.region_key || region.label || getRegionName(card) || '').trim();
|
||||||
|
setRegionRemovalState(card);
|
||||||
var nameInput = card.querySelector('[name="region_name[]"]');
|
var nameInput = card.querySelector('[name="region_name[]"]');
|
||||||
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
var lockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||||
var widthInput = card.querySelector('[name="region_width[]"]');
|
var widthInput = card.querySelector('[name="region_width[]"]');
|
||||||
@@ -415,12 +448,12 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
card.addEventListener('click', function (event) {
|
card.addEventListener('click', function (event) {
|
||||||
if (event.target && event.target.classList && event.target.classList.contains('remove-region')) {
|
if (event.target && event.target.closest && event.target.closest('[data-region-remove-button]')) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setSelected(getCards().indexOf(card));
|
setSelected(getCards().indexOf(card));
|
||||||
});
|
});
|
||||||
card.querySelector('.remove-region').addEventListener('click', function (event) {
|
card.querySelector('[data-region-remove-button]').addEventListener('click', function (event) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
card.remove();
|
card.remove();
|
||||||
if (!getCards().length) {
|
if (!getCards().length) {
|
||||||
@@ -523,7 +556,9 @@
|
|||||||
if (hint) {
|
if (hint) {
|
||||||
hint.remove();
|
hint.remove();
|
||||||
}
|
}
|
||||||
regionList.appendChild(makeRegionCard(region));
|
var card = makeRegionCard(region);
|
||||||
|
setRegionRemovalState(card);
|
||||||
|
regionList.appendChild(card);
|
||||||
setSelected(getCards().length - 1);
|
setSelected(getCards().length - 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -600,8 +635,12 @@
|
|||||||
var currentPoint = toCanvasPoint(moveEvent);
|
var currentPoint = toCanvasPoint(moveEvent);
|
||||||
var dx = currentPoint.x - startPoint.x;
|
var dx = currentPoint.x - startPoint.x;
|
||||||
var dy = currentPoint.y - startPoint.y;
|
var dy = currentPoint.y - startPoint.y;
|
||||||
var next = clampRegion({ x: startRegion.x + dx, y: startRegion.y + dy, width: startRegion.width, height: startRegion.height });
|
var size = getCanvasSize();
|
||||||
writeCard(cardAt(index), { x: next.x, y: next.y });
|
var maxX = Math.max(0, size.width - startRegion.width);
|
||||||
|
var maxY = Math.max(0, size.height - startRegion.height);
|
||||||
|
var nextX = clamp(startRegion.x + dx, 0, maxX);
|
||||||
|
var nextY = clamp(startRegion.y + dy, 0, maxY);
|
||||||
|
writeCard(cardAt(index), { x: nextX, y: nextY, width: startRegion.width, height: startRegion.height });
|
||||||
requestOverlayRender();
|
requestOverlayRender();
|
||||||
}
|
}
|
||||||
function upHandler() {
|
function upHandler() {
|
||||||
@@ -617,6 +656,24 @@
|
|||||||
var startRegion = readCard(cardAt(index));
|
var startRegion = readCard(cardAt(index));
|
||||||
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
var lockRatio = normalizeLockRatio(startRegion.lock_ratio);
|
||||||
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
var aspect = lockRatio ? parseLockRatio(lockRatio) : null;
|
||||||
|
var canvasSize = getCanvasSize();
|
||||||
|
var minSize = 12;
|
||||||
|
|
||||||
|
function clampResizeDelta(dx, dy) {
|
||||||
|
if (dir.indexOf('w') !== -1) {
|
||||||
|
dx = clamp(dx, -startRegion.x, startRegion.width - minSize);
|
||||||
|
}
|
||||||
|
if (dir.indexOf('e') !== -1) {
|
||||||
|
dx = clamp(dx, minSize - startRegion.width, canvasSize.width - startRegion.x - startRegion.width);
|
||||||
|
}
|
||||||
|
if (dir.indexOf('n') !== -1) {
|
||||||
|
dy = clamp(dy, -startRegion.y, startRegion.height - minSize);
|
||||||
|
}
|
||||||
|
if (dir.indexOf('s') !== -1) {
|
||||||
|
dy = clamp(dy, minSize - startRegion.height, canvasSize.height - startRegion.y - startRegion.height);
|
||||||
|
}
|
||||||
|
return { dx: dx, dy: dy };
|
||||||
|
}
|
||||||
|
|
||||||
function fitFromWidth(width) {
|
function fitFromWidth(width) {
|
||||||
var nextWidth = Math.max(12, Math.round(width));
|
var nextWidth = Math.max(12, Math.round(width));
|
||||||
@@ -638,6 +695,9 @@
|
|||||||
var currentPoint = toCanvasPoint(moveEvent);
|
var currentPoint = toCanvasPoint(moveEvent);
|
||||||
var dx = currentPoint.x - startPoint.x;
|
var dx = currentPoint.x - startPoint.x;
|
||||||
var dy = currentPoint.y - startPoint.y;
|
var dy = currentPoint.y - startPoint.y;
|
||||||
|
var constrainedDelta = clampResizeDelta(dx, dy);
|
||||||
|
dx = constrainedDelta.dx;
|
||||||
|
dy = constrainedDelta.dy;
|
||||||
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
var next = { x: startRegion.x, y: startRegion.y, width: startRegion.width, height: startRegion.height };
|
||||||
if (aspect) {
|
if (aspect) {
|
||||||
if (dir === 'e') {
|
if (dir === 'e') {
|
||||||
@@ -748,7 +808,6 @@
|
|||||||
}
|
}
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setSelected(-1);
|
setSelected(-1);
|
||||||
startDraw(event);
|
|
||||||
});
|
});
|
||||||
if (templateForm) {
|
if (templateForm) {
|
||||||
templateForm.addEventListener('formdata', function (event) {
|
templateForm.addEventListener('formdata', function (event) {
|
||||||
|
|||||||
@@ -0,0 +1,94 @@
|
|||||||
|
(function () {
|
||||||
|
function escapeHtml(value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDashboardDate(value) {
|
||||||
|
if (!value) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
var date = new Date(value);
|
||||||
|
if (Number.isNaN(date.getTime())) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Intl.DateTimeFormat('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: 'numeric',
|
||||||
|
minute: '2-digit',
|
||||||
|
second: '2-digit'
|
||||||
|
}).format(date);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientRowKey(client) {
|
||||||
|
return String(client && client.id ? client.id : client && client.clientId ? client.clientId : '').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getClientDisplayName(client) {
|
||||||
|
if (client && client.client_name) {
|
||||||
|
return String(client.client_name).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
var clientId = String(client && client.clientId ? client.clientId : '').trim();
|
||||||
|
if (clientId) {
|
||||||
|
return clientId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function setButtonVariant(button, classesToRemove, classToAdd) {
|
||||||
|
if (!button) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (button.classList) {
|
||||||
|
classesToRemove.forEach(function (className) {
|
||||||
|
button.classList.remove(className);
|
||||||
|
});
|
||||||
|
if (classToAdd) {
|
||||||
|
button.classList.add(classToAdd);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var className = String(button.className || '');
|
||||||
|
classesToRemove.forEach(function (removeClass) {
|
||||||
|
className = className.replace(new RegExp('(^|\\s)' + removeClass + '(?=\\s|$)', 'g'), ' ');
|
||||||
|
});
|
||||||
|
if (classToAdd) {
|
||||||
|
className += ' ' + classToAdd;
|
||||||
|
}
|
||||||
|
button.className = className.replace(/\s+/g, ' ').trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDisplayIp(value) {
|
||||||
|
var ip = String(value || '').trim();
|
||||||
|
if (!ip) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ip.toLowerCase().indexOf('::ffff:') === 0) {
|
||||||
|
return ip.slice(7).trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
return ip;
|
||||||
|
}
|
||||||
|
|
||||||
|
window.webUiHelpers = {
|
||||||
|
escapeHtml: escapeHtml,
|
||||||
|
formatDashboardDate: formatDashboardDate,
|
||||||
|
getClientRowKey: getClientRowKey,
|
||||||
|
getClientDisplayName: getClientDisplayName,
|
||||||
|
setButtonVariant: setButtonVariant,
|
||||||
|
normalizeDisplayIp: normalizeDisplayIp
|
||||||
|
};
|
||||||
|
}());
|
||||||
@@ -1,3 +1,6 @@
|
|||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
module.exports = function registerAdminContentRoutes(app, deps) {
|
module.exports = function registerAdminContentRoutes(app, deps) {
|
||||||
const pool = deps.pool;
|
const pool = deps.pool;
|
||||||
const common = deps.common;
|
const common = deps.common;
|
||||||
@@ -23,6 +26,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
const { buildPagination } = require('../../lib/pagination');
|
const { buildPagination } = require('../../lib/pagination');
|
||||||
|
|
||||||
const LIST_PAGE_SIZE = 10;
|
const LIST_PAGE_SIZE = 10;
|
||||||
|
const IMAGE_UPLOAD_MAX_BYTES = 100 * 1024 * 1024;
|
||||||
|
const VIDEO_UPLOAD_MAX_BYTES = 1024 * 1024 * 1024;
|
||||||
|
|
||||||
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
if (!pool || !common || !pages || !upload || typeof fetchScreensBySlideId !== 'function' || typeof fetchScreensByTemplateId !== 'function' || typeof collectUploadReferencesFromSlide !== 'function' || typeof collectUploadReferencesFromTemplate !== 'function' || typeof collectUploadReferencesFromPayload !== 'function' || typeof removeUnusedUploadFiles !== 'function' || typeof syncPlaylistUploadsOnChange !== 'function' || typeof getAuditUserId !== 'function' || typeof redirectAfterSave !== 'function' || typeof notifyPlayerScreens !== 'function' || typeof broadcastDashboardState !== 'function' || typeof getSlideDeleteBlockMessage !== 'function' || typeof getTemplateDeleteBlockMessage !== 'function' || typeof getCanvasSizeDeleteBlockMessage !== 'function' || typeof hasAnyPermission !== 'function') {
|
||||||
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
|
throw new Error('registerAdminContentRoutes requires the content route dependencies.');
|
||||||
@@ -43,6 +48,111 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getUploadedFileMediaType(file) {
|
||||||
|
const mimeType = String(file && file.mimetype || '').trim().toLowerCase();
|
||||||
|
const extension = path.extname(String(file && file.originalname || '')).toLowerCase();
|
||||||
|
if (mimeType.indexOf('video/') === 0 || ['.mp4', '.webm', '.ogg', '.ogv', '.mov', '.avi', '.mkv'].indexOf(extension) !== -1) {
|
||||||
|
return 'video';
|
||||||
|
}
|
||||||
|
if (mimeType.indexOf('image/') === 0 || ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.bmp', '.avif', '.tif', '.tiff'].indexOf(extension) !== -1) {
|
||||||
|
return 'image';
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadedFileLimitBytes(file) {
|
||||||
|
return getUploadedFileMediaType(file) === 'video' ? VIDEO_UPLOAD_MAX_BYTES : IMAGE_UPLOAD_MAX_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUploadedFileLimitLabel(file) {
|
||||||
|
return getUploadedFileMediaType(file) === 'video' ? '1 GB' : '100 MB';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeUploadedFile(file) {
|
||||||
|
if (!file || !file.filename || !deps.uploadDir) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filePath = path.join(deps.uploadDir, file.filename);
|
||||||
|
try {
|
||||||
|
await fs.promises.unlink(filePath);
|
||||||
|
} catch (_error) {
|
||||||
|
// Ignore cleanup failures.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function validateUploadedFiles(files) {
|
||||||
|
const list = Array.isArray(files) ? files.filter(Boolean) : [];
|
||||||
|
for (let i = 0; i < list.length; i += 1) {
|
||||||
|
const file = list[i];
|
||||||
|
const mediaType = getUploadedFileMediaType(file);
|
||||||
|
if (!mediaType) {
|
||||||
|
await removeUploadedFile(file);
|
||||||
|
const error = new Error('Unsupported upload type.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
error.expose = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Number(file.size || 0) > getUploadedFileLimitBytes(file)) {
|
||||||
|
await removeUploadedFile(file);
|
||||||
|
const error = new Error('File must be ' + getUploadedFileLimitLabel(file) + ' or smaller.');
|
||||||
|
error.statusCode = 400;
|
||||||
|
error.expose = true;
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplateSlideCount(templateId) {
|
||||||
|
const [rows] = await pool.query('SELECT COUNT(*) AS slide_count FROM slides WHERE template_id = ?', [templateId]);
|
||||||
|
return Number(rows[0] && rows[0].slide_count) || 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchTemplateRegionUsage(template) {
|
||||||
|
const [slides] = await pool.query('SELECT content_json FROM slides WHERE template_id = ?', [template.id]);
|
||||||
|
const usage = new Set();
|
||||||
|
const regionKeys = new Set((Array.isArray(template && template.regions) ? template.regions : [])
|
||||||
|
.map((region) => String(region && region.region_key || '').trim())
|
||||||
|
.filter(Boolean));
|
||||||
|
|
||||||
|
if (!regionKeys.size || !slides.length) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
slides.forEach((slide) => {
|
||||||
|
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
|
||||||
|
if (!content || typeof content !== 'object') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
regionKeys.forEach((regionKey) => {
|
||||||
|
if (Object.prototype.hasOwnProperty.call(content, regionKey)) {
|
||||||
|
usage.add(regionKey);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return Array.from(usage);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getRemovedTemplateRegionKeys(existingRegions, nextRegions) {
|
||||||
|
const nextKeys = new Set((Array.isArray(nextRegions) ? nextRegions : []).map((region) => String(region && region.region_key || '').trim()).filter(Boolean));
|
||||||
|
return (Array.isArray(existingRegions) ? existingRegions : [])
|
||||||
|
.map((region) => String(region && region.region_key || '').trim())
|
||||||
|
.filter((regionKey) => regionKey && !nextKeys.has(regionKey));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTemplateRegionDeleteBlockMessage(template, nextRegions) {
|
||||||
|
const removedRegionKeys = getRemovedTemplateRegionKeys(template && template.regions ? template.regions : [], nextRegions);
|
||||||
|
if (!removedRegionKeys.length) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const usedRegionKeys = Array.isArray(template && template.region_usage) ? template.region_usage : [];
|
||||||
|
const blockedKeys = removedRegionKeys.filter((regionKey) => usedRegionKeys.includes(regionKey));
|
||||||
|
return blockedKeys.length > 0 ? 'This region is still used by one or more slides.' : '';
|
||||||
|
}
|
||||||
|
|
||||||
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
function queueSlideThumbnailRefresh(slideId, previousThumbnailPath) {
|
||||||
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
if (!backgroundTaskQueue || typeof backgroundTaskQueue.enqueueTask !== 'function') {
|
||||||
return Promise.resolve(null);
|
return Promise.resolve(null);
|
||||||
@@ -168,6 +278,8 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
return res.status(400).json({ error: 'No file was uploaded.' });
|
return res.status(400).json({ error: 'No file was uploaded.' });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await validateUploadedFiles([req.file]);
|
||||||
|
|
||||||
res.json({
|
res.json({
|
||||||
path: '/media/uploads/' + req.file.filename,
|
path: '/media/uploads/' + req.file.filename,
|
||||||
filename: req.file.filename,
|
filename: req.file.filename,
|
||||||
@@ -195,6 +307,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
|
|
||||||
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
app.post('/slides', requirePermission('slides.create'), upload.any(), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
|
await validateUploadedFiles(req.files || []);
|
||||||
const payload = await common.buildSlidePayload(pool, req, null);
|
const payload = await common.buildSlidePayload(pool, req, null);
|
||||||
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
if (await common.fetchDuplicateName(pool, 'slides', payload.title, null, 'title')) {
|
||||||
return res.status(400).send('A slide with that title already exists.');
|
return res.status(400).send('A slide with that title already exists.');
|
||||||
@@ -225,6 +338,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
|
|
||||||
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
app.post('/slides/:id', requirePermission('slides.update'), upload.any(), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
|
await validateUploadedFiles(req.files || []);
|
||||||
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
const slide = await common.fetchSlideById(pool, Number(req.params.id));
|
||||||
if (!slide) {
|
if (!slide) {
|
||||||
return res.status(404).send('Slide not found');
|
return res.status(404).send('Slide not found');
|
||||||
@@ -358,6 +472,7 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
if (!template) {
|
if (!template) {
|
||||||
return res.status(404).send('Template not found');
|
return res.status(404).send('Template not found');
|
||||||
}
|
}
|
||||||
|
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||||
const sizeData = await common.fetchCanvasSizesData(pool);
|
const sizeData = await common.fetchCanvasSizesData(pool);
|
||||||
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
res.send(pages.renderTemplateFormPage(template, 'edit', req.query.message ? String(req.query.message) : '', sizeData.canvasSizes, req.currentUser));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -371,11 +486,16 @@ module.exports = function registerAdminContentRoutes(app, deps) {
|
|||||||
if (!template) {
|
if (!template) {
|
||||||
return res.status(404).send('Template not found');
|
return res.status(404).send('Template not found');
|
||||||
}
|
}
|
||||||
|
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||||
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
|
||||||
const payload = await common.buildTemplatePayload(pool, req, template);
|
const payload = await common.buildTemplatePayload(pool, req, template);
|
||||||
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
if (await common.fetchDuplicateName(pool, 'slide_templates', payload.name, template.id)) {
|
||||||
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent('A template with that name already exists.'));
|
||||||
}
|
}
|
||||||
|
const regionDeleteBlockMessage = await getTemplateRegionDeleteBlockMessage(template, payload.regions);
|
||||||
|
if (regionDeleteBlockMessage) {
|
||||||
|
return res.redirect('/templates/' + template.id + '/edit?message=' + encodeURIComponent(regionDeleteBlockMessage));
|
||||||
|
}
|
||||||
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
|
||||||
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
|
|||||||
@@ -116,6 +116,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
|
|
||||||
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
const slideIds = readArrayField(req.body, ['slide_id[]', 'slide_id']);
|
||||||
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
|
const durations = readArrayField(req.body, ['duration_seconds[]', 'duration_seconds']);
|
||||||
|
const useVideoDurations = readArrayField(req.body, ['use_video_duration[]', 'use_video_duration']);
|
||||||
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
|
const scheduleModes = readArrayField(req.body, ['schedule_mode[]', 'schedule_mode']);
|
||||||
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
const scheduleStartDateTimes = readArrayField(req.body, ['schedule_start_datetime[]', 'schedule_start_datetime']);
|
||||||
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
const scheduleEndDateTimes = readArrayField(req.body, ['schedule_end_datetime[]', 'schedule_end_datetime']);
|
||||||
@@ -126,6 +127,9 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
if (durations.length && durations.length !== slideIds.length) {
|
if (durations.length && durations.length !== slideIds.length) {
|
||||||
return res.status(400).send('Playlist slide data is invalid.');
|
return res.status(400).send('Playlist slide data is invalid.');
|
||||||
}
|
}
|
||||||
|
if (useVideoDurations.length && useVideoDurations.length !== slideIds.length) {
|
||||||
|
return res.status(400).send('Playlist slide data is invalid.');
|
||||||
|
}
|
||||||
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
|
if (scheduleModes.length && scheduleModes.length !== slideIds.length) {
|
||||||
return res.status(400).send('Playlist schedule data is invalid.');
|
return res.status(400).send('Playlist schedule data is invalid.');
|
||||||
}
|
}
|
||||||
@@ -143,7 +147,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
seenSlideIds.add(slideId);
|
seenSlideIds.add(slideId);
|
||||||
|
|
||||||
const durationRaw = Number(durations[i]);
|
const durationRaw = Number(durations[i]);
|
||||||
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(1, Math.trunc(durationRaw)) : 10;
|
const durationSeconds = Number.isFinite(durationRaw) ? Math.max(0.001, Math.round(durationRaw * 1000) / 1000) : 10;
|
||||||
|
const useVideoDuration = String(useVideoDurations[i] || '') === '1' ? 1 : 0;
|
||||||
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
|
const scheduleMode = normalizeScheduleMode(scheduleModes[i]);
|
||||||
|
|
||||||
let scheduleStartDatetime = null;
|
let scheduleStartDatetime = null;
|
||||||
@@ -188,6 +193,7 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
slideId,
|
slideId,
|
||||||
position: i,
|
position: i,
|
||||||
durationSeconds,
|
durationSeconds,
|
||||||
|
useVideoDuration,
|
||||||
scheduleMode,
|
scheduleMode,
|
||||||
scheduleStartDatetime,
|
scheduleStartDatetime,
|
||||||
scheduleEndDatetime,
|
scheduleEndDatetime,
|
||||||
@@ -227,12 +233,13 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
for (let i = 0; i < normalizedSlides.length; i += 1) {
|
||||||
const item = normalizedSlides[i];
|
const item = normalizedSlides[i];
|
||||||
await connection.query(
|
await connection.query(
|
||||||
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, schedule_mode, schedule_start_datetime, schedule_end_datetime, schedule_start_time, schedule_end_time, schedule_days_json, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
||||||
[
|
[
|
||||||
playlist.id,
|
playlist.id,
|
||||||
item.slideId,
|
item.slideId,
|
||||||
item.position,
|
item.position,
|
||||||
item.durationSeconds,
|
item.durationSeconds,
|
||||||
|
item.useVideoDuration,
|
||||||
item.scheduleMode,
|
item.scheduleMode,
|
||||||
item.scheduleStartDatetime,
|
item.scheduleStartDatetime,
|
||||||
item.scheduleEndDatetime,
|
item.scheduleEndDatetime,
|
||||||
@@ -303,11 +310,12 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
if (playlistCanvasSignature && slideCanvasSignature !== playlistCanvasSignature) {
|
||||||
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
return res.status(400).send('The slide canvas size must match the existing playlist items.');
|
||||||
}
|
}
|
||||||
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
|
const durationValue = Number(req.body.duration_seconds || 10);
|
||||||
|
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||||
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
const [positionRows] = await pool.query('SELECT COALESCE(MAX(position), -1) AS max_position FROM playlist_slides WHERE playlist_id = ?', [playlist.id]);
|
||||||
const nextPosition = Number(positionRows[0].max_position) + 1;
|
const nextPosition = Number(positionRows[0].max_position) + 1;
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, actorId, actorId]);
|
await pool.query('INSERT INTO playlist_slides (playlist_id, slide_id, position, duration_seconds, use_video_duration, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?)', [playlist.id, slideId, nextPosition, durationSeconds, 0, actorId, actorId]);
|
||||||
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
await notifyPlayerScreens(await fetchScreensByPlaylistId(pool, playlist.id), 'refresh');
|
||||||
await broadcastDashboardState();
|
await broadcastDashboardState();
|
||||||
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
res.redirect('/playlists?edit=' + playlist.id + '&message=' + encodeURIComponent('Slide added to playlist.'));
|
||||||
@@ -323,7 +331,8 @@ module.exports = function registerAdminManageRoutes(app, deps) {
|
|||||||
return res.status(404).send('Playlist not found');
|
return res.status(404).send('Playlist not found');
|
||||||
}
|
}
|
||||||
const playlistSlideId = Number(req.params.playlistSlideId);
|
const playlistSlideId = Number(req.params.playlistSlideId);
|
||||||
const durationSeconds = Math.max(1, Number(req.body.duration_seconds || 10));
|
const durationValue = Number(req.body.duration_seconds || 10);
|
||||||
|
const durationSeconds = Number.isFinite(durationValue) ? Math.max(0.001, Math.round(durationValue * 1000) / 1000) : 10;
|
||||||
const actorId = getAuditUserId(req);
|
const actorId = getAuditUserId(req);
|
||||||
const [result] = await pool.query(
|
const [result] = await pool.query(
|
||||||
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
'UPDATE playlist_slides SET duration_seconds = ?, modified_by = ? WHERE id = ? AND playlist_id = ?',
|
||||||
|
|||||||
@@ -61,6 +61,86 @@ function getCanvasSignature(width, height) {
|
|||||||
return normalizedWidth + 'x' + normalizedHeight;
|
return normalizedWidth + 'x' + normalizedHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasVideoRegion(contentJson) {
|
||||||
|
if (!contentJson) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||||
|
return Boolean(parsed && typeof parsed === 'object' && Object.keys(parsed).some((key) => {
|
||||||
|
const region = parsed[key];
|
||||||
|
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video';
|
||||||
|
}));
|
||||||
|
} catch (_error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoSourcePath(slide) {
|
||||||
|
if (!slide) {
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
const directMediaPath = String(slide.media_path || '').trim();
|
||||||
|
if (String(slide.media_type || '').trim().toLowerCase() === 'video' && directMediaPath) {
|
||||||
|
return directMediaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentJson = slide.content_json;
|
||||||
|
if (!contentJson) {
|
||||||
|
return directMediaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return directMediaPath;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||||
|
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && String(region.value || '').trim();
|
||||||
|
});
|
||||||
|
|
||||||
|
return videoRegion ? String(videoRegion.value || '').trim() : directMediaPath;
|
||||||
|
} catch (_error) {
|
||||||
|
return directMediaPath;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoDurationSeconds(slide) {
|
||||||
|
if (!slide) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentJson = slide.content_json;
|
||||||
|
if (!contentJson) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = typeof contentJson === 'string' ? JSON.parse(contentJson) : contentJson;
|
||||||
|
if (!parsed || typeof parsed !== 'object') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const videoRegion = Object.keys(parsed).map((key) => parsed[key]).find((region) => {
|
||||||
|
return region && typeof region === 'object' && String(region.type || '').trim().toLowerCase() === 'video' && Number(region.duration_seconds || 0) > 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
const duration = Math.round(Number(videoRegion && videoRegion.duration_seconds || 0) * 1000) / 1000;
|
||||||
|
return Number.isFinite(duration) && duration > 0 ? duration : null;
|
||||||
|
} catch (_error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getVideoDurationValue(slide) {
|
||||||
|
const storedDuration = Number(slide && slide.duration_seconds || 0);
|
||||||
|
const videoDuration = getVideoDurationSeconds(slide);
|
||||||
|
return Boolean(slide && slide.use_video_duration) && videoDuration ? videoDuration : storedDuration;
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
|
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
|
||||||
const playlistSlides = (data.playlistSlides || [])
|
const playlistSlides = (data.playlistSlides || [])
|
||||||
.filter((item) => item.playlist_id === playlist.id)
|
.filter((item) => item.playlist_id === playlist.id)
|
||||||
@@ -75,6 +155,11 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
|||||||
isFirst: index === 0,
|
isFirst: index === 0,
|
||||||
isLast: index === items.length - 1,
|
isLast: index === items.length - 1,
|
||||||
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
|
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
|
||||||
|
showVideoDurationButton: String(item.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(item.content_json),
|
||||||
|
videoSourcePath: getVideoSourcePath(item),
|
||||||
|
videoDurationSeconds: getVideoDurationSeconds(item),
|
||||||
|
useVideoDuration: Boolean(item.use_video_duration),
|
||||||
|
durationSeconds: getVideoDurationValue(item),
|
||||||
scheduleSummary: scheduleSummary(item)
|
scheduleSummary: scheduleSummary(item)
|
||||||
}));
|
}));
|
||||||
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
|
||||||
@@ -91,7 +176,12 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
|||||||
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
|
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
|
||||||
}).map((slide) => Object.assign({}, slide, {
|
}).map((slide) => Object.assign({}, slide, {
|
||||||
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
|
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
|
||||||
isAssigned: assignedSlideIds.has(slide.id)
|
isAssigned: assignedSlideIds.has(slide.id),
|
||||||
|
showVideoDurationButton: String(slide.media_type || '').trim().toLowerCase() === 'video' || hasVideoRegion(slide.content_json),
|
||||||
|
videoSourcePath: getVideoSourcePath(slide),
|
||||||
|
videoDurationSeconds: getVideoDurationSeconds(slide),
|
||||||
|
useVideoDuration: Boolean(slide.use_video_duration),
|
||||||
|
durationSeconds: getVideoDurationValue(slide)
|
||||||
}));
|
}));
|
||||||
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
|
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
|
||||||
|
|
||||||
@@ -111,6 +201,6 @@ module.exports = function renderPlaylistEditPage(playlist, data, message, curren
|
|||||||
selectableSlides: selectableSlides,
|
selectableSlides: selectableSlides,
|
||||||
playlistCanvasSignature: playlistCanvasSignature,
|
playlistCanvasSignature: playlistCanvasSignature,
|
||||||
playlistCanvasMismatch: playlistCanvasMismatch,
|
playlistCanvasMismatch: playlistCanvasMismatch,
|
||||||
scripts: ['js/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']
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ function buildDefaultTemplate() {
|
|||||||
canvas_size_height: 1080,
|
canvas_size_height: 1080,
|
||||||
background_color: '#111111',
|
background_color: '#111111',
|
||||||
background_image_path: '',
|
background_image_path: '',
|
||||||
|
region_usage: [],
|
||||||
regions: []
|
regions: []
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,9 @@ module.exports = function renderTemplatesPage(data, message, currentUser) {
|
|||||||
name: template.name,
|
name: template.name,
|
||||||
canvas_size_width: template.canvas_size_width,
|
canvas_size_width: template.canvas_size_width,
|
||||||
canvas_size_height: template.canvas_size_height,
|
canvas_size_height: template.canvas_size_height,
|
||||||
|
slideCount: template.slide_count !== undefined
|
||||||
|
? Number(template.slide_count) || 0
|
||||||
|
: 0,
|
||||||
regionCount: template.region_count !== undefined
|
regionCount: template.region_count !== undefined
|
||||||
? Number(template.region_count) || 0
|
? Number(template.region_count) || 0
|
||||||
: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<div class="card-header d-flex align-items-center">
|
<div class="card-header d-flex align-items-center">
|
||||||
<h3 class="card-title mb-0">Scheduled Tasks</h3>
|
<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="ms-auto d-flex flex-nowrap align-items-center background-tasks-recurring-tools">
|
||||||
<div class="input-group input-group-sm background-tasks-recurring-search" style="width: min(12rem, 100%);">
|
<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>
|
<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 />
|
<input type="search" class="form-control" placeholder="Search scheduled" aria-label="Search scheduled refreshes" data-table-search />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html data-bs-theme="dark" style="color-scheme: dark;">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
@@ -82,6 +82,7 @@
|
|||||||
{{/if}}
|
{{/if}}
|
||||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||||
|
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||||
<script src="/assets/js/admin/toast.js"></script>
|
<script src="/assets/js/admin/toast.js"></script>
|
||||||
<script src="/assets/js/admin/admin-page.js"></script>
|
<script src="/assets/js/admin/admin-page.js"></script>
|
||||||
<script src="/assets/js/admin/system-status.js"></script>
|
<script src="/assets/js/admin/system-status.js"></script>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<!doctype html>
|
<!doctype html>
|
||||||
<html>
|
<html data-bs-theme="dark" style="color-scheme: dark;">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="utf-8" />
|
<meta charset="utf-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
{{/each}}
|
{{/each}}
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</head>
|
</head>
|
||||||
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}">
|
<body class="hold-transition layout-fixed sidebar-expand-lg bg-body-tertiary {{#if authShell}}login-page{{/if}} {{bodyClass}}"{{#if authShell}} data-bs-theme="dark" style="color-scheme: dark;"{{else if errorShell}} data-bs-theme="dark" style="color-scheme: dark;"{{/if}}>
|
||||||
{{#if errorShell}}
|
{{#if errorShell}}
|
||||||
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
|
<main class="d-flex align-items-center justify-content-center min-vh-100 p-3 {{bodyClass}}">
|
||||||
{{{body}}}
|
{{{body}}}
|
||||||
@@ -284,6 +284,7 @@
|
|||||||
</dialog>
|
</dialog>
|
||||||
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
<script src="/assets/adminlte/js/bootstrap.bundle.min.js"></script>
|
||||||
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
<script src="/assets/adminlte/js/adminlte.min.js"></script>
|
||||||
|
<script src="/assets/js/web-ui-helpers.js"></script>
|
||||||
<script src="/assets/js/admin/toast.js"></script>
|
<script src="/assets/js/admin/toast.js"></script>
|
||||||
<script src="/assets/js/table-sort.js"></script>
|
<script src="/assets/js/table-sort.js"></script>
|
||||||
<script src="/assets/js/admin/admin-page.js"></script>
|
<script src="/assets/js/admin/admin-page.js"></script>
|
||||||
|
|||||||
@@ -83,7 +83,7 @@
|
|||||||
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}">
|
<tbody id="playlist-items-body" data-playlist-id="{{playlist.id}}">
|
||||||
{{#if playlistSlides.length}}
|
{{#if playlistSlides.length}}
|
||||||
{{#each playlistSlides}}
|
{{#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">
|
<td class="playlist-order-cell" data-label="Order">
|
||||||
<div class="playlist-order-cell-inner">
|
<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">
|
<button type="button" class="playlist-drag-handle btn btn-link p-0 text-body-secondary" data-playlist-drag-handle aria-label="Drag to reorder" title="Drag to reorder">
|
||||||
@@ -123,7 +123,16 @@
|
|||||||
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
|
<input type="hidden" name="schedule_days_json[]" value="{{schedule_days_json}}" form="playlist-edit-form" />
|
||||||
</td>
|
</td>
|
||||||
<td data-label="Duration">
|
<td data-label="Duration">
|
||||||
<input name="duration_seconds[]" type="number" min="1" value="{{duration_seconds}}" required form="playlist-edit-form" 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>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
<div class="actions playlist-item-actions">
|
<div class="actions playlist-item-actions">
|
||||||
|
|||||||
@@ -113,6 +113,6 @@
|
|||||||
|
|
||||||
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
<textarea id="slide-editor-data" hidden>{{json slideEditorData}}</textarea>
|
||||||
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
|
<script src="/assets/vendor/cropperjs/cropper.min.js"></script>
|
||||||
<script type="module" src="/assets/js/slides/slide-form.js?v=20260726.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.4"></script>
|
<script type="module" src="/assets/js/slides/slide-image-cropper.js?v=20260726.5"></script>
|
||||||
|
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form">
|
<form method="post" action="/templates" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}">
|
||||||
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
||||||
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
||||||
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
||||||
<input type="hidden" id="canvas-size-summary" />
|
<input type="hidden" id="canvas-size-summary" />
|
||||||
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
||||||
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
||||||
|
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
|
||||||
|
|
||||||
<div class="template-designer-layout">
|
<div class="template-designer-layout">
|
||||||
<div class="card card-outline card-secondary admin-form-card mb-3">
|
<div class="card card-outline card-secondary admin-form-card mb-3">
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="template-designer-sidebar">
|
<div class="template-designer-sidebar">
|
||||||
<div class="card card-outline card-primary admin-form-card">
|
<div class="card card-outline card-primary admin-form-card">
|
||||||
<div class="card-body d-grid gap-2">
|
<div class="card-body d-grid">
|
||||||
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
|
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
|
||||||
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}}
|
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" showSaveAndClose=false showSaveAndNew=false}}}
|
||||||
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||||
@@ -114,19 +115,20 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
|
<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="html">HTML</button>
|
<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="image">Image</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>
|
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</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="text">Text</button>
|
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</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="webpage">Webpage</button>
|
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/modal-shell}}
|
{{/modal-shell}}
|
||||||
|
|
||||||
<template id="region-card-template">
|
<template id="region-card-template">
|
||||||
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
|
<div class="card card-outline card-secondary admin-form-card region-item">
|
||||||
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
||||||
<div class="card-body p-3 d-grid gap-3">
|
<div class="card-body p-3 d-grid gap-3">
|
||||||
<div class="region-field-grid region-field-grid--identity">
|
<div class="region-field-grid region-field-grid--identity">
|
||||||
@@ -145,8 +147,8 @@
|
|||||||
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
||||||
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
|
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
|
||||||
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
|
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
|
||||||
|
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button>Remove</button></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -5,13 +5,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new">
|
<form method="post" action="/templates/{{template.id}}" enctype="multipart/form-data" id="template-form" data-template-has-slides="{{#if template.slide_count}}true{{else}}false{{/if}}" data-async-save data-async-save-close-url="/templates" data-async-save-new-url="/templates/new">
|
||||||
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
<input type="hidden" name="canvas_size_id" id="canvas-size-id" value="{{template.canvas_size_id}}" />
|
||||||
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
<input type="hidden" name="canvas_width" id="canvas-width" value="{{template.canvas_size_width}}" />
|
||||||
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
<input type="hidden" name="canvas_height" id="canvas-height" value="{{template.canvas_size_height}}" />
|
||||||
<input type="hidden" id="canvas-size-summary" />
|
<input type="hidden" id="canvas-size-summary" />
|
||||||
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
<input type="hidden" name="existing_background_image_path" value="{{template.background_image_path}}" />
|
||||||
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
<input type="hidden" name="regions_json" id="regions-json" value="" />
|
||||||
|
<textarea id="template-region-usage" hidden>{{json template.region_usage}}</textarea>
|
||||||
|
|
||||||
<div class="template-designer-layout">
|
<div class="template-designer-layout">
|
||||||
<div class="card card-outline card-secondary admin-form-card mb-3">
|
<div class="card card-outline card-secondary admin-form-card mb-3">
|
||||||
@@ -30,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="template-designer-sidebar">
|
<div class="template-designer-sidebar">
|
||||||
<div class="card card-outline card-primary admin-form-card">
|
<div class="card card-outline card-primary admin-form-card">
|
||||||
<div class="card-body d-grid gap-2">
|
<div class="card-body d-grid">
|
||||||
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
|
<div class="btn-group template-actions-group" role="group" aria-label="Template actions">
|
||||||
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
{{{saveActionButtons formId="template-form" saveLabel="Save" saveButtonClass="btn btn-success template-save-button" saveAndCloseLabel="Save and Close" saveAndNewLabel="Save and New" saveAndCloseValue="close" saveAndNewValue="new"}}}
|
||||||
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
<a class="btn btn-warning" href="/templates" data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</a>
|
||||||
@@ -121,19 +122,20 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
<p class="text-body-secondary mb-3">Choose the type of region you want to add to this template.</p>
|
||||||
<div class="d-grid gap-2">
|
<div class="d-grid gap-2">
|
||||||
|
<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="html">HTML</button>
|
<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="image">Image</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>
|
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="rss">RSS</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="text">Text</button>
|
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="text">Text</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="webpage">Webpage</button>
|
<button type="button" class="btn btn-outline-secondary text-start" data-add-region-type="webpage">Webpage</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/modal-shell}}
|
{{/modal-shell}}
|
||||||
|
|
||||||
<template id="region-card-template">
|
<template id="region-card-template">
|
||||||
<div class="card card-outline card-secondary admin-form-card region-item mb-3">
|
<div class="card card-outline card-secondary admin-form-card region-item">
|
||||||
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
<div class="card-header template-field-head"><strong data-region-title>Region</strong><span class="chip" data-region-chip>Text</span></div>
|
||||||
<div class="card-body p-3 d-grid gap-3">
|
<div class="card-body p-3 d-grid gap-3">
|
||||||
<div class="region-field-grid region-field-grid--identity">
|
<div class="region-field-grid region-field-grid--identity">
|
||||||
@@ -152,8 +154,8 @@
|
|||||||
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
<label>Width<input class="form-control" type="number" name="region_width[]" value="300" required /></label>
|
||||||
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
|
<label>Height<input class="form-control" type="number" name="region_height[]" value="120" required /></label>
|
||||||
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
|
<label>Lock ratio<input class="form-control" type="text" name="region_lock_ratio[]" value="" placeholder="16:9 or 5:4" /></label>
|
||||||
|
<label>Remove<button type="button" class="btn btn-sm btn-outline-danger w-100" data-region-remove-button disabled>Remove</button></label>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex justify-content-end"><button type="button" class="btn btn-sm btn-outline-danger remove-region">Remove</button></div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -25,6 +25,7 @@
|
|||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Canvas</th>
|
<th>Canvas</th>
|
||||||
<th>Regions</th>
|
<th>Regions</th>
|
||||||
|
<th>Slides used by</th>
|
||||||
<th>Actions</th>
|
<th>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
@@ -35,6 +36,7 @@
|
|||||||
<td data-label="Name">{{name}}</td>
|
<td data-label="Name">{{name}}</td>
|
||||||
<td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td>
|
<td data-label="Canvas">{{canvas_size_width}}x{{canvas_size_height}}</td>
|
||||||
<td data-label="Regions">{{regionCount}}</td>
|
<td data-label="Regions">{{regionCount}}</td>
|
||||||
|
<td data-label="Slides used by">{{slideCount}}</td>
|
||||||
<td data-label="Actions">
|
<td data-label="Actions">
|
||||||
{{#if (anyPermission ../currentUser 'templates.update' 'templates.delete')}}
|
{{#if (anyPermission ../currentUser 'templates.update' 'templates.delete')}}
|
||||||
<div class="actions">
|
<div class="actions">
|
||||||
@@ -54,7 +56,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{{/each}}
|
{{/each}}
|
||||||
{{else}}
|
{{else}}
|
||||||
<tr data-table-search-empty-default><td colspan="4" class="empty">No templates yet.</td></tr>
|
<tr data-table-search-empty-default><td colspan="5" class="empty">No templates yet.</td></tr>
|
||||||
{{/if}}
|
{{/if}}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
Reference in New Issue
Block a user