From 24f1603b433200b680a4636fd54fb8ac87ec8e1b Mon Sep 17 00:00:00 2001 From: Mark Rapson Date: Sat, 24 Oct 2026 18:03:38 +0100 Subject: [PATCH] Release v2.18.0: template default content --- CHANGELOG.md | 7 + build/package.player.json | 2 +- build/package.web.json | 2 +- docs/technical/schema.md | 2 +- docs/user/guide-web.md | 2 + package-lock.json | 4 +- package.json | 2 +- src/data/admin.js | 2 +- src/data/api-source-groups.js | 4 +- src/data/slides.js | 7 + src/data/templates.js | 31 ++- src/db/index.js | 2 + src/db/migrations.js | 8 + src/player/playlist.js | 16 +- src/web/lib/media/library.js | 10 +- src/web/lib/media/slide-thumbnail-preview.js | 11 +- src/web/lib/media/upload-sync.js | 13 +- src/web/public/css/theme-custom.css | 214 +++++++++++++++- src/web/public/js/media-picker.js | 32 +++ .../public/js/playlists/playlist-schedule.js | 29 ++- .../js/slides/slide-animation-editor.js | 27 +- src/web/public/js/slides/slide-form.js | 175 ++++++++++++- .../public/js/slides/slide-layout-editor.js | 7 +- .../template-default-content-editor.js | 233 ++++++++++++++++++ .../js/templates/template-designer-utils.js | 12 + .../public/js/templates/template-designer.js | 76 ++++++ src/web/routes/admin/content.js | 47 ++-- .../routes/data-sources/rss-feeds/routes.js | 44 +--- .../routes/data-sources/timetables/routes.js | 16 +- src/web/routes/data-sources/weather.js | 15 +- src/web/routes/settings/fonts.js | 14 +- src/web/routes/settings/weather-icons.js | 37 ++- src/web/routes/signage/templates/add.js | 4 +- src/web/routes/signage/templates/edit.js | 4 +- .../signage/templates/form-view-model.js | 3 +- src/web/views/shared/media-picker-modal.hbs | 2 +- src/web/views/signage/playlists/form.hbs | 4 +- .../signage/playlists/schedule-rule-card.hbs | 3 +- .../views/signage/playlists/slide-config.hbs | 8 +- src/web/views/signage/slides/form.hbs | 30 ++- .../templates/animation-advanced-modal.hbs | 2 +- src/web/views/signage/templates/form.hbs | 33 +++ test/action-buttons.test.js | 20 ++ test/api-source-groups.test.js | 6 +- test/fonts-routes.test.js | 37 +++ test/media-library.test.js | 11 +- test/player-playlist.test.js | 8 +- test/playlists-duplicate.test.js | 50 ++++ test/region-layout.test.js | 75 +++++- test/schema-update-log.test.js | 12 + test/slide-form-editor.test.js | 31 +++ test/template-default-dependencies.test.js | 41 +++ test/templates.test.js | 79 ++++++ test/upload-sync.test.js | 6 +- 54 files changed, 1421 insertions(+), 151 deletions(-) create mode 100644 src/web/public/js/templates/template-default-content-editor.js create mode 100644 test/template-default-dependencies.test.js diff --git a/CHANGELOG.md b/CHANGELOG.md index d822d26..789335c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,13 @@ All notable changes to this project will be documented in this file. +## 2.18.0 - 2026-10-24 + +### Added + +- Templates can define region defaults with the same type-specific editors used by slides, including rich-text editing and a full-canvas preview. Editable regions start from template defaults and accept slide overrides; Template-only regions stay out of slide editing and layout/animation overrides while retaining their template content and animations in previews and playback. +- Media, managed fonts, API requests, RSS feeds, timetable groups, weather locations, and weather icon sets referenced by template defaults are protected from deletion. + ## 2.17.5 - 2026-10-23 ### Added diff --git a/build/package.player.json b/build/package.player.json index e46ad91..ebf4720 100644 --- a/build/package.player.json +++ b/build/package.player.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-player", - "version": "2.17.5", + "version": "2.18.0", "private": false, "description": "Pulse Signage player application bundle", "engines": { diff --git a/build/package.web.json b/build/package.web.json index 8a32ee8..ba8c084 100644 --- a/build/package.web.json +++ b/build/package.web.json @@ -1,6 +1,6 @@ { "name": "pulse-signage-web", - "version": "2.17.5", + "version": "2.18.0", "private": false, "description": "Pulse Signage web and bridge application bundle", "engines": { diff --git a/docs/technical/schema.md b/docs/technical/schema.md index 1e8d7ef..c65f9f1 100644 --- a/docs/technical/schema.md +++ b/docs/technical/schema.md @@ -106,7 +106,7 @@ Tables generally use a numeric auto-increment `id` primary key. The relationship ### `c_template_regions` -- `id`, `template_id`, `region_key`, `region_type`, `label`, `font_family`, `lock_ratio`, `animation_json`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by` +- `id`, `template_id`, `region_key`, `region_type`, `label`, `font_family`, `lock_ratio`, `animation_json`, `default_content_json`, `read_only`, `x`, `y`, `width`, `height`, `z_index`, `created_at`, `created_by`, `modified_at`, `modified_by` - Foreign key: - `template_id` -> `c_templates.id` with `ON DELETE CASCADE` diff --git a/docs/user/guide-web.md b/docs/user/guide-web.md index db88575..2781a14 100644 --- a/docs/user/guide-web.md +++ b/docs/user/guide-web.md @@ -132,6 +132,8 @@ Each region has layout controls for: Use names such as `headline`, `hero_image`, `event_time`, or `room_schedule` instead of generic names. Stable names make slides easier to edit and make template changes easier to understand. Keep regions large enough for their longest expected value; a region that fits a short title may clip a longer announcement. +Use **Edit default content** to set a region's starting content with the same type-specific editor used on slides, including the rich-text editor for text. Slides use these defaults until a slide-specific value is saved. Select **Read-only on slides** for fixed content such as a logo, footer, or branded label; it remains visible in previews and playback but is omitted from the slide editor and ignores saved slide overrides. Read-only regions can still be changed in the template editor. + #### Region Types The available region types cover fixed content, uploaded media, live data, and external content: diff --git a/package-lock.json b/package-lock.json index bbd5923..6ca9a68 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "pulse-signage", - "version": "2.17.5", + "version": "2.18.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pulse-signage", - "version": "2.17.5", + "version": "2.18.0", "dependencies": { "animate.css": "^4.1.1", "bootstrap-icons": "1.13.1", diff --git a/package.json b/package.json index a9e5578..ff9108d 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pulse-signage", - "version": "2.17.5", + "version": "2.18.0", "private": false, "description": "Pulse Signage application with MySQL and media storage", "engines": { diff --git a/src/data/admin.js b/src/data/admin.js index 4473879..bcf0869 100644 --- a/src/data/admin.js +++ b/src/data/admin.js @@ -12,7 +12,7 @@ async function fetchAdminData(pool) { LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC `); - const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); + const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, default_content_json AS default_content, read_only, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); const [slides] = await pool.query(` SELECT s.id, s.title, s.template_id, s.content_json, s.thumbnail_path, s.created_at, s.modified_at, s.created_by, s.modified_by, st.name AS template_name, st.canvas_size_id, cs.width AS canvas_width, cs.height AS canvas_height, (SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count diff --git a/src/data/api-source-groups.js b/src/data/api-source-groups.js index ff2224f..e51780c 100644 --- a/src/data/api-source-groups.js +++ b/src/data/api-source-groups.js @@ -65,6 +65,7 @@ async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction) async function getApiRequestUsage(pool) { const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL'); const ids = new Set(); function walk(value) { if (!value || typeof value !== 'object') return; @@ -72,6 +73,7 @@ async function getApiRequestUsage(pool) { Object.values(value).forEach(walk); } slides.forEach(function (slide) { walk(parseJsonSafe(slide.content_json)); }); + templateRegions.forEach(function (region) { walk(parseJsonSafe(region.default_content_json)); }); return ids; } @@ -95,7 +97,7 @@ async function saveApiGroup(pool, id, payload, actorId, canDelete) { if (removed.length) { if (!canDelete) throw validationError('You do not have permission to remove API requests.', 403); const usage = await getApiRequestUsage(connection); - if (removed.some(function (requestId) { return usage.has(requestId); })) throw validationError('A request being removed is still used by one or more slides.'); + if (removed.some(function (requestId) { return usage.has(requestId); })) throw validationError('A request being removed is still used by slides or template defaults.'); } const values = [payload.name].concat(API_AUTH_FIELDS.map(function (field) { return payload.auth[field] === undefined ? null : payload.auth[field]; })); if (id) { diff --git a/src/data/slides.js b/src/data/slides.js index a8f6e94..a98d120 100644 --- a/src/data/slides.js +++ b/src/data/slides.js @@ -309,6 +309,12 @@ async function getRssFeedItemCount(pool, feedId) { async function buildTemplateContent(pool, template, body, filesByField, existingContent) { const content = {}; for (const region of template.regions) { + if (region.read_only === true || Number(region.read_only) === 1) { + continue; + } + if (!Object.prototype.hasOwnProperty.call(existingContent, region.region_key) && region.default_content && typeof region.default_content === 'object') { + existingContent[region.region_key] = region.default_content; + } if (region.region_type === 'image') { const uploaded = filesByField[`region_image_${region.id}`]; const existing = body[`existing_region_image_${region.id}`]; @@ -640,6 +646,7 @@ async function buildSlidePayload(pool, req, existingSlide) { } const canvas = { width: template.canvas_size_width, height: template.canvas_size_height }; for (const region of template.regions) { + if (region.read_only === true || Number(region.read_only) === 1) continue; const key = region.region_key; const value = layouts ? (Object.hasOwn(layouts, key) ? layouts[key] : null) : Number(existingSlide && existingSlide.template_id) === template.id && existingContent[key] && existingContent[key].layout; diff --git a/src/data/templates.js b/src/data/templates.js index 8ba5001..159fbc1 100644 --- a/src/data/templates.js +++ b/src/data/templates.js @@ -67,6 +67,25 @@ function normalizeTemplateRegionLockRatio(value) { return rawRatio.replace(/\s+/g, ''); } +function normalizeTemplateRegionDefaultContent(value) { + let content = value; + if (typeof content === 'string') { + content = parseJsonSafe(content); + } + return content && typeof content === 'object' && !Array.isArray(content) ? content : {}; +} + +function normalizeTemplateRegionReadOnly(value) { + return value === true || value === 1 || String(value || '').trim().toLowerCase() === 'true' || String(value || '').trim() === '1'; +} + +function normalizeFetchedTemplateRegion(region) { + return Object.assign({}, region, { + default_content: normalizeTemplateRegionDefaultContent(region.default_content), + read_only: normalizeTemplateRegionReadOnly(region.read_only) + }); +} + function normalizeTemplateRegionAnimation(value) { if (animationPresets && typeof animationPresets.normalize === 'function') { return animationPresets.normalize(value); @@ -156,8 +175,8 @@ async function fetchTemplateById(pool, id) { return null; } const template = templates[0]; - const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]); - template.regions = regions; + const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json AS default_content, read_only, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]); + template.regions = regions.map(normalizeFetchedTemplateRegion); return template; } @@ -169,8 +188,8 @@ async function fetchTemplatesData(pool) { LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id ORDER BY st.id DESC `); - const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); - return { templates, templateRegions }; + const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json AS default_content, read_only, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC'); + return { templates, templateRegions: templateRegions.map(normalizeFetchedTemplateRegion) }; } function extractTemplateRegions(body) { @@ -186,6 +205,8 @@ function extractTemplateRegions(body) { region_type: regionType, label: regionName, lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio), + default_content: normalizeTemplateRegionDefaultContent(region.default_content), + read_only: normalizeTemplateRegionReadOnly(region.read_only), animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json), x: Number(region.x || 0), y: Number(region.y || 0), @@ -222,6 +243,8 @@ function extractTemplateRegions(body) { region_type: regionType, label: name, lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]), + default_content: {}, + read_only: false, animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]), x: Number(xs[i] || 0), y: Number(ys[i] || 0), diff --git a/src/db/index.js b/src/db/index.js index 776b851..80837c0 100644 --- a/src/db/index.js +++ b/src/db/index.js @@ -79,6 +79,8 @@ async function ensureSchema(pool, options) { font_family VARCHAR(100) NULL, lock_ratio VARCHAR(20) NULL, animation_json JSON NULL, + default_content_json JSON NULL, + read_only TINYINT(1) NOT NULL DEFAULT 0, x INT NOT NULL DEFAULT 0, y INT NOT NULL DEFAULT 0, width INT NOT NULL DEFAULT 100, diff --git a/src/db/migrations.js b/src/db/migrations.js index 3564e2c..1336487 100644 --- a/src/db/migrations.js +++ b/src/db/migrations.js @@ -761,6 +761,14 @@ const VERSIONED_MIGRATIONS = [ await ensureIndex(pool, 'i_api_source_groups', 'idx_api_source_groups_modified_at', 'modified_at'); await dropIndexIfExists(pool, 'o_background_tasks', 'idx_background_tasks_status'); } + }, + { + version: '2.18.0', + label: 'v2.18.0 template region default content schema', + run: async function (pool) { + await ensureColumn(pool, 'c_template_regions', 'default_content_json', 'JSON NULL', 'animation_json'); + await ensureColumn(pool, 'c_template_regions', 'read_only', 'TINYINT(1) NOT NULL DEFAULT 0', 'default_content_json'); + } } ]; diff --git a/src/player/playlist.js b/src/player/playlist.js index 39cda48..52ca84f 100644 --- a/src/player/playlist.js +++ b/src/player/playlist.js @@ -245,7 +245,7 @@ function createPlayerPlaylistService(options) { LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id WHERE st.id IN (?) `, [templateIds]); - [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); + [regionRows] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json AS default_content, read_only, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id IN (?) ORDER BY template_id ASC, z_index ASC, id ASC', [templateIds]); templateRows.forEach(function (template) { template.regions = regionRows.filter(function (region) { return region.template_id === template.id; }); templatesById[template.id] = template; @@ -279,10 +279,21 @@ function createPlayerPlaylistService(options) { const slides = await Promise.all(slideRows.map(async function (slide) { const storedDuration = Number(slide.duration_seconds || 0); - const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(slide.content_json) : null; const disableAudio = slide.disable_audio === undefined || slide.disable_audio === null ? true : Boolean(slide.disable_audio); const videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || ''); const content = common.parseJsonSafe(slide.content_json) || {}; + const template = slide.template_id ? templatesById[slide.template_id] || null : null; + if (template) { + template.regions.forEach(function (region) { + let defaultContent = region.default_content; + if (typeof defaultContent === 'string') defaultContent = common.parseJsonSafe(defaultContent); + if (!defaultContent || typeof defaultContent !== 'object' || Array.isArray(defaultContent)) defaultContent = {}; + if (region.read_only === true || Number(region.read_only) === 1 || !Object.prototype.hasOwnProperty.call(content, region.region_key)) { + content[region.region_key] = Object.assign({ type: region.region_type }, defaultContent); + } + }); + } + const videoDuration = slide.use_video_duration ? getVideoRegionDurationSeconds(JSON.stringify(content)) : null; const qrBackfillTasks = []; Object.keys(content).forEach(function (key) { const region = content[key]; @@ -308,7 +319,6 @@ function createPlayerPlaylistService(options) { } })); - const template = slide.template_id ? templatesById[slide.template_id] || null : null; const groupScheduleRules = slide.group_schedule_rules_json ? common.parseJsonSafe(slide.group_schedule_rules_json) || [] : []; diff --git a/src/web/lib/media/library.js b/src/web/lib/media/library.js index efb1384..960ce5a 100644 --- a/src/web/lib/media/library.js +++ b/src/web/lib/media/library.js @@ -257,7 +257,15 @@ async function countMediaAssetReferences(pool, mediaPath) { 'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?', [normalizedPath] ); - return Number(slideRows[0] && slideRows[0].ref_count || 0) + Number(templateRows[0] && templateRows[0].ref_count || 0); + const [templateRegionRows] = await pool.query( + `SELECT COUNT(*) AS ref_count + FROM c_template_regions + WHERE LOCATE(?, CAST(COALESCE(default_content_json, '') AS CHAR)) > 0`, + [normalizedPath] + ); + return Number(slideRows[0] && slideRows[0].ref_count || 0) + + Number(templateRows[0] && templateRows[0].ref_count || 0) + + Number(templateRegionRows[0] && templateRegionRows[0].ref_count || 0); } async function publishMediaAssets(pool, mediaPaths) { diff --git a/src/web/lib/media/slide-thumbnail-preview.js b/src/web/lib/media/slide-thumbnail-preview.js index 87beec8..1554915 100644 --- a/src/web/lib/media/slide-thumbnail-preview.js +++ b/src/web/lib/media/slide-thumbnail-preview.js @@ -69,8 +69,15 @@ function getThumbnailCanvasSize(slide) { } function getRegionContent(slide, region) { - const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {}; - return content && typeof content === 'object' ? content : { value: content }; + const saved = slide && slide.content && slide.content[region.region_key]; + let selected = region.read_only === true || Number(region.read_only) === 1 + ? region.default_content + : (saved === undefined ? region.default_content : saved); + if (typeof selected === 'string') { + try { selected = JSON.parse(selected); } catch (_error) { selected = { value: selected }; } + } + const content = selected && typeof selected === 'object' ? selected : { value: selected }; + return Object.assign({ type: region.region_type }, content || {}); } function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) { diff --git a/src/web/lib/media/upload-sync.js b/src/web/lib/media/upload-sync.js index 197410f..4455033 100644 --- a/src/web/lib/media/upload-sync.js +++ b/src/web/lib/media/upload-sync.js @@ -348,6 +348,9 @@ function createUploadSyncService(options) { return refs; } collectUploadReferencesFromValue(template.background_image_path, refs); + (Array.isArray(template.regions) ? template.regions : []).forEach(function (region) { + collectUploadReferencesFromValue(region && region.default_content, refs); + }); return refs; } @@ -358,6 +361,9 @@ function createUploadSyncService(options) { } collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs); collectUploadReferencesFromValue(payload.backgroundImagePath, refs); + (Array.isArray(payload.regions) ? payload.regions : []).forEach(function (region) { + collectUploadReferencesFromValue(region && region.default_content, refs); + }); return refs; } @@ -376,7 +382,12 @@ function createUploadSyncService(options) { 'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?', [uploadPath] ); - return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0); + const [templateRegionRows] = await pool.query( + `SELECT COUNT(*) AS ref_count FROM c_template_regions + WHERE JSON_SEARCH(COALESCE(default_content_json, JSON_OBJECT()), 'one', ?) IS NOT NULL`, + [uploadPath] + ); + return Number(slideRows[0].ref_count || 0) + Number(thumbnailRows[0].ref_count || 0) + Number(templateRows[0].ref_count || 0) + Number(templateRegionRows[0].ref_count || 0); } async function uploadFileExists(uploadDir, uploadPath) { diff --git a/src/web/public/css/theme-custom.css b/src/web/public/css/theme-custom.css index 1b3aca4..a41eba6 100644 --- a/src/web/public/css/theme-custom.css +++ b/src/web/public/css/theme-custom.css @@ -2626,7 +2626,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { } #slide-layout-modal, -#slide-animation-modal { +#slide-animation-modal, +#region-default-content-modal { left: var(--slide-layout-left, 0px); width: calc(100% - var(--slide-layout-left, 0px)); } @@ -2640,6 +2641,189 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { margin: 2rem auto; } +.template-picker-current { + display: flex; + align-items: baseline; + gap: .5rem; + min-width: 0; + overflow: hidden; +} + +.template-picker-current strong { + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.template-picker-toolbar { + display: grid; + grid-template-columns: minmax(14rem, 1fr) minmax(12rem, 20rem); + gap: 1rem; + align-items: center; +} + +.template-picker-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(15rem, 1fr)); + gap: 1rem; +} + +.template-picker-option { + min-width: 0; + padding: 0; + overflow: hidden; + text-align: left; + color: var(--bs-body-color); + background: var(--bs-body-bg); + border: 1px solid var(--bs-border-color); + border-radius: 4px; +} + +.template-picker-option:hover, +.template-picker-option:focus-visible { + border-color: var(--bs-primary); + outline: none; +} + +.template-picker-option[aria-pressed="true"] { + border: 2px solid var(--bs-primary); + box-shadow: 0 0 0 2px color-mix(in srgb, var(--bs-primary) 22%, transparent); +} + +.template-picker-preview { + position: relative; + width: 100%; + overflow: hidden; + background-color: #111; + aspect-ratio: 16 / 9; +} + +.template-picker-preview-none { + display: grid; + place-items: center; + color: var(--bs-secondary-color); + background-image: repeating-linear-gradient(135deg, transparent 0 12px, rgba(128, 128, 128, .12) 12px 13px); +} + +.template-picker-preview-background, +.template-picker-region-layer { + position: absolute; + inset: 0; + width: 100%; + height: 100%; +} + +.template-picker-preview-background { + object-fit: cover; +} + +.template-picker-region-layer { + pointer-events: none; +} + +.template-picker-region { + position: absolute; + border: 1px solid rgba(255, 255, 255, .82); + background: rgba(50, 150, 170, .16); +} + +.template-picker-details { + display: grid; + gap: .2rem; + padding: .65rem .75rem; +} + +.template-picker-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.template-default-content-dialog { + width: calc(100% - 2rem); + max-width: none; + height: calc(100dvh - 4rem); + max-height: 860px; + min-height: 0; + margin: 2rem auto; +} + +.template-default-content-dialog .modal-content { + height: 100%; +} + +.slide-animation-body.template-default-content-body { + grid-template-columns: minmax(0, 1fr) 21rem; +} + +.template-default-content-workspace { + container-type: size; + display: flex; + align-items: center; + justify-content: center; + min-width: 0; + min-height: 0; +} + +.template-default-content-preview-stage { + position: relative; + width: min(100%, calc(100cqh * var(--template-preview-ratio, 1.777778))); + flex-shrink: 0; + overflow: hidden; + outline: 1px solid var(--bs-border-color); + background: #111; + isolation: isolate; +} + +.template-default-content-preview-frame { + position: absolute; + inset: 0; + overflow: hidden; +} + +.template-default-content-preview-background, +.template-default-content-preview-canvas { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: block; + transform-origin: top left; +} + +.template-default-content-preview-background { + object-fit: fill; +} + +.template-default-content-preview-label { + position: absolute; + top: 4px; + left: 4px; + max-width: calc(100% - 8px); + overflow: hidden; + padding: 2px 4px; + color: #fff; + background: rgba(0, 0, 0, .65); + font-size: 14px; + line-height: 1.2; + text-overflow: ellipsis; + white-space: nowrap; + pointer-events: none; +} + +.template-default-content-properties { + min-width: 0; + overflow-y: auto; + overflow-x: hidden; + padding: 0 4px; +} + +.template-default-content-properties .template-field-card { + margin-bottom: 0 !important; +} + .slide-layout-dialog .modal-content { height: 100%; } @@ -2831,12 +3015,26 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { } @media (max-width: 767.98px) { + .template-picker-toolbar { + grid-template-columns: minmax(0, 1fr); + } + + .template-picker-grid { + grid-template-columns: repeat(auto-fill, minmax(10rem, 1fr)); + } + .slide-layout-dialog { width: calc(100% - 1rem); height: calc(100dvh - 2rem); margin: 1rem auto; } + .template-default-content-dialog { + width: calc(100% - 1rem); + height: calc(100dvh - 2rem); + margin: 1rem auto; + } + .slide-layout-body { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(14rem, 1fr) auto; @@ -2846,6 +3044,16 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { .slide-layout-properties { overflow: visible; } + + .slide-animation-body.template-default-content-body { + grid-template-columns: minmax(0, 1fr); + grid-template-rows: auto minmax(14rem, 1fr); + overflow-y: auto; + } + + .template-default-content-properties { + overflow: visible; + } } /* Slide preview canvas and popup */ @@ -4199,6 +4407,10 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection { scrollbar-gutter: stable both-edges; } +.slide-schedule-card > form > .card-footer::after { + content: none; +} + .slide-schedule-content .schedule-rule-card .card-header { align-items: flex-start; } diff --git a/src/web/public/js/media-picker.js b/src/web/public/js/media-picker.js index 407d522..2a5a568 100644 --- a/src/web/public/js/media-picker.js +++ b/src/web/public/js/media-picker.js @@ -7,6 +7,9 @@ } var activeButton = null; + var nestedParentModal = null; + var nestedParentFocusTrap = null; + var nestedReturnFocus = null; var items = Array.prototype.slice.call(modal.querySelectorAll('[data-media-picker-item]')); var grid = modal.querySelector('[data-media-picker-grid]'); var search = modal.querySelector('[data-media-picker-search]'); @@ -90,6 +93,27 @@ } function showModal() { + var openModals = Array.prototype.slice.call(document.querySelectorAll('.modal.show')) + .filter(function (openModal) { return openModal !== modal; }); + nestedParentModal = openModals.length ? openModals[openModals.length - 1] : null; + if (nestedParentModal) { + nestedReturnFocus = activeButton; + var parentInstance = window.pulseModal && typeof window.pulseModal.getOrCreate === 'function' + ? window.pulseModal.getOrCreate(nestedParentModal) + : window.bootstrap && window.bootstrap.Modal + ? window.bootstrap.Modal.getOrCreateInstance(nestedParentModal) + : null; + nestedParentFocusTrap = parentInstance && parentInstance._focustrap || null; + if (nestedParentFocusTrap && typeof nestedParentFocusTrap.deactivate === 'function') nestedParentFocusTrap.deactivate(); + var parentZIndex = Number.parseInt(window.getComputedStyle(nestedParentModal).zIndex, 10) || 1055; + var pickerZIndex = parentZIndex + 20; + modal.style.zIndex = String(pickerZIndex); + modal.addEventListener('shown.bs.modal', function () { + var backdrops = document.querySelectorAll('.modal-backdrop'); + var backdrop = backdrops[backdrops.length - 1]; + if (backdrop) backdrop.style.zIndex = String(pickerZIndex - 5); + }, { once: true }); + } if (window.pulseModal && typeof window.pulseModal.show === 'function') { window.pulseModal.show(modal); } else if (window.bootstrap && window.bootstrap.Modal) { @@ -431,5 +455,13 @@ currentPath = ''; pendingPath = ''; pendingType = ''; + modal.style.removeProperty('z-index'); + if (nestedParentModal && nestedParentModal.classList.contains('show')) { + if (nestedParentFocusTrap && typeof nestedParentFocusTrap.activate === 'function') nestedParentFocusTrap.activate(); + if (nestedReturnFocus && document.contains(nestedReturnFocus)) nestedReturnFocus.focus(); + } + nestedParentModal = null; + nestedParentFocusTrap = null; + nestedReturnFocus = null; }); })(); diff --git a/src/web/public/js/playlists/playlist-schedule.js b/src/web/public/js/playlists/playlist-schedule.js index 4930cda..63a3f6a 100644 --- a/src/web/public/js/playlists/playlist-schedule.js +++ b/src/web/public/js/playlists/playlist-schedule.js @@ -1127,7 +1127,8 @@ '
' + '' + '' + '
' + @@ -1330,13 +1331,8 @@ return card ? card.querySelector('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null; } - function getCollapseRuleIcon(card) { - return card ? card.querySelector('[data-schedule-rule-collapse-icon]') : null; - } - function syncRuleCollapseButtonState(card) { var button = getCollapseRuleButton(card); - var icon = getCollapseRuleIcon(card); var numberNode = getRuleNumberNode(card); var ruleIndex = numberNode ? String(numberNode.textContent || '') : ''; var isCollapsed = Boolean(card && card.classList && card.classList.contains('collapsed-card')); @@ -1350,11 +1346,6 @@ button.setAttribute('aria-label', label); button.setAttribute('title', label); - if (icon) { - icon.classList.remove('bi-dash-lg', 'bi-plus-lg'); - icon.classList.add(isCollapsed ? 'bi-plus-lg' : 'bi-dash-lg'); - } - var visibleLabel = button.querySelector('.visually-hidden'); if (visibleLabel) { visibleLabel.textContent = label; @@ -1742,15 +1733,23 @@ handleCardChange(card); }); + function handleRuleCollapseComplete(event) { + var card = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-card]') : null; + if (!card) { + return; + } + syncRuleCollapseButtonState(card); + persistScheduleDraft(); + } + + scheduleRuleList.addEventListener('collapsed.lte.card-widget', handleRuleCollapseComplete); + scheduleRuleList.addEventListener('expanded.lte.card-widget', handleRuleCollapseComplete); + scheduleRuleList.addEventListener('click', function (event) { var removeButton = event.target && event.target.closest ? event.target.closest('[data-remove-schedule-rule]') : null; var collapseButton = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null; if (collapseButton) { - window.setTimeout(function () { - syncRuleCollapseButtonState(collapseButton.closest('[data-schedule-rule-card]')); - persistScheduleDraft(); - }, 0); return; } diff --git a/src/web/public/js/slides/slide-animation-editor.js b/src/web/public/js/slides/slide-animation-editor.js index ae940f8..0e87cf1 100644 --- a/src/web/public/js/slides/slide-animation-editor.js +++ b/src/web/public/js/slides/slide-animation-editor.js @@ -25,10 +25,11 @@ export function createSlideAnimationEditor(options) { var nextSibling = null; function template() { return options.getTemplate(); } - function regions() { return template() && template().regions || []; } + function allRegions() { return template() && template().regions || []; } + function regions() { return allRegions().filter(function (region) { return !(region.read_only === true || Number(region.read_only) === 1); }); } function selectedKey() { return regionSelect.value; } function getConfig(regionKey) { - var region = regions().find(function (item) { return item.region_key === regionKey; }); + var region = allRegions().find(function (item) { return item.region_key === regionKey; }); return Object.prototype.hasOwnProperty.call(draft, regionKey) ? draft[regionKey] : region && region.animation_json || '{}'; @@ -49,7 +50,17 @@ export function createSlideAnimationEditor(options) { function renderRegion() { var key = selectedKey(); var region = regions().find(function (item) { return item.region_key === key; }); - if (!region) return; + if (!region) { + card.querySelectorAll('input, select, button, textarea').forEach(function (element) { + element.disabled = true; + }); + updatePreviewButtons(); + updateResetButtons(); + return; + } + card.querySelectorAll('input, select, button, textarea').forEach(function (element) { + element.disabled = false; + }); controls.write(card, getConfig(key)); updatePreviewButtons(); updateResetButtons(); @@ -87,8 +98,8 @@ export function createSlideAnimationEditor(options) { return; } saveStatus.textContent = 'Saving shared animations...'; - var animations = regions().map(function (region) { - return { region_key: region.region_key, animation_json: getConfig(region.region_key) }; + var animations = allRegions().map(function (region) { + return { region_key: region.region_key, animation_json: region.read_only === true || Number(region.read_only) === 1 ? region.animation_json || '{}' : getConfig(region.region_key) }; }); var result = await readResponse(await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, @@ -138,7 +149,7 @@ export function createSlideAnimationEditor(options) { var modeKeys = { intro: 'intro', outro: 'outro', loop: 'loop' }; previewButtons.forEach(function (button) { var mode = button.dataset.slideAnimationPreview; - var available = regions().some(function (region) { + var available = allRegions().some(function (region) { var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(region.region_key)); return window.templateAnimationPresets.normalize(config[modeKeys[mode]].preset) !== 'none'; }); @@ -184,7 +195,7 @@ export function createSlideAnimationEditor(options) { }); openButton.addEventListener('click', function () { - if (!regions().length || !window.pulseModal) return; + if (!allRegions().length || !window.pulseModal) return; regionSelect.replaceChildren(); regions().forEach(function (region) { var option = document.createElement('option'); @@ -296,5 +307,5 @@ export function createSlideAnimationEditor(options) { window.pulseModal.hide(modal); }); - return { refresh: function () { openButton.disabled = active || !regions().length; } }; + return { refresh: function () { openButton.disabled = active || !allRegions().length; } }; } diff --git a/src/web/public/js/slides/slide-form.js b/src/web/public/js/slides/slide-form.js index 7ec5ddc..82f18bd 100644 --- a/src/web/public/js/slides/slide-form.js +++ b/src/web/public/js/slides/slide-form.js @@ -28,8 +28,16 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed window.pulseWeatherIconSets = Array.isArray(slideEditorData.weatherIconSets) ? slideEditorData.weatherIconSets : []; var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim(); var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null; - var existingContent = slideEditorData.existingContent || {}; + var savedExistingContent = slideEditorData.existingContent || {}; + var existingContent = Object.assign({}, savedExistingContent); var templateSelect = document.getElementById('template-select'); + var templatePickerModal = document.getElementById('template-picker-modal'); + var templatePickerOpenButton = document.getElementById('template-picker-open'); + var templatePickerSelectedName = document.getElementById('template-picker-selected-name'); + var templatePickerSearch = document.getElementById('template-picker-search'); + var templatePickerCanvasSize = document.getElementById('template-picker-canvas-size'); + var templatePickerGrid = document.getElementById('template-picker-grid'); + var templatePickerEmpty = document.getElementById('template-picker-empty'); var templateFields = document.getElementById('template-fields'); var slideForm = document.getElementById('slide-form'); var slideEditorShell = document.querySelector('.slide-editor-shell'); @@ -113,6 +121,134 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed return templates.find(function (item) { return Number(item.id) === Number(id); }) || null; } + function getTemplateCanvasSignature(template) { + return (Number(template && template.canvas_size_width) || 1920) + 'x' + (Number(template && template.canvas_size_height) || 1080); + } + + function populateTemplateCanvasSizes() { + if (!templatePickerCanvasSize) return; + var canvasSizes = new Map(); + templates.forEach(function (template) { + var width = Number(template && template.canvas_size_width) || 1920; + var height = Number(template && template.canvas_size_height) || 1080; + var signature = width + 'x' + height; + if (!canvasSizes.has(signature)) { + var name = String(template && template.canvas_size_name || '').trim(); + canvasSizes.set(signature, { signature: signature, label: (name ? name + ' (' : '') + width + ' x ' + height + (name ? ')' : '') }); + } + }); + Array.from(canvasSizes.values()).sort(function (left, right) { + return left.label.localeCompare(right.label, undefined, { numeric: true, sensitivity: 'base' }); + }).forEach(function (size) { + var option = document.createElement('option'); + option.value = size.signature; + option.textContent = size.label; + templatePickerCanvasSize.appendChild(option); + }); + } + + function createTemplatePickerCard(template) { + var card = document.createElement('button'); + card.type = 'button'; + card.className = 'template-picker-option'; + card.setAttribute('role', 'option'); + card.dataset.templatePickerId = String(template.id); + card.setAttribute('aria-pressed', String(Number(template.id) === Number(templateSelect.value))); + + var width = Math.max(1, Number(template.canvas_size_width) || 1920); + var height = Math.max(1, Number(template.canvas_size_height) || 1080); + var preview = document.createElement('div'); + preview.className = 'template-picker-preview'; + preview.style.aspectRatio = width + ' / ' + height; + preview.style.backgroundColor = /^#[0-9a-f]{3,8}$/i.test(String(template.background_color || '')) ? template.background_color : '#111111'; + if (template.background_image_path) { + var backgroundImage = document.createElement('img'); + backgroundImage.className = 'template-picker-preview-background'; + backgroundImage.src = String(template.background_image_path); + backgroundImage.alt = ''; + backgroundImage.loading = 'lazy'; + preview.appendChild(backgroundImage); + } + var regionLayer = document.createElement('span'); + regionLayer.className = 'template-picker-region-layer'; + (Array.isArray(template.regions) ? template.regions : []).forEach(function (region) { + var box = document.createElement('span'); + box.className = 'template-picker-region'; + box.title = String(region.label || region.region_key || 'Region'); + box.style.left = Math.max(0, Number(region.x) || 0) / width * 100 + '%'; + box.style.top = Math.max(0, Number(region.y) || 0) / height * 100 + '%'; + box.style.width = Math.min(100, Math.max(0, Number(region.width) || 0) / width * 100) + '%'; + box.style.height = Math.min(100, Math.max(0, Number(region.height) || 0) / height * 100) + '%'; + regionLayer.appendChild(box); + }); + preview.appendChild(regionLayer); + card.appendChild(preview); + + var details = document.createElement('span'); + details.className = 'template-picker-details'; + var name = document.createElement('strong'); + name.className = 'template-picker-name'; + name.textContent = String(template.name || 'Untitled template'); + var size = document.createElement('small'); + size.className = 'text-body-secondary'; + size.textContent = width + ' x ' + height; + details.appendChild(name); + details.appendChild(size); + card.appendChild(details); + card.addEventListener('click', function () { selectTemplate(template.id); }); + return card; + } + + function renderTemplatePicker() { + if (!templatePickerGrid) return; + var query = String(templatePickerSearch && templatePickerSearch.value || '').trim().toLowerCase(); + var selectedCanvasSize = String(templatePickerCanvasSize && templatePickerCanvasSize.value || ''); + var matches = templates.filter(function (template) { + var canvasMatches = !selectedCanvasSize || getTemplateCanvasSignature(template) === selectedCanvasSize; + return canvasMatches && String(template.name || '').toLowerCase().includes(query); + }); + templatePickerGrid.replaceChildren(); + matches.forEach(function (template) { templatePickerGrid.appendChild(createTemplatePickerCard(template)); }); + if (templatePickerEmpty) templatePickerEmpty.hidden = matches.length > 0; + } + + function syncTemplatePickerSummary() { + if (!templateSelect) return; + var selectedTemplate = getSelectedTemplate(); + if (templatePickerSelectedName) templatePickerSelectedName.textContent = selectedTemplate ? String(selectedTemplate.name || 'Untitled template') : 'No template selected'; + if (templatePickerOpenButton) { + templatePickerOpenButton.disabled = Boolean(templateSelect.disabled); + templatePickerOpenButton.title = templateSelect.disabled ? templateSelect.title || 'Template is locked after you edit its content.' : 'Choose a template'; + } + } + + function selectTemplate(templateId) { + if (!templateSelect || templateSelect.disabled) return; + templateSelect.value = templateId === null || templateId === undefined ? '' : String(templateId); + templateSelect.dispatchEvent(new Event('change', { bubbles: true })); + if (window.pulseModal && templatePickerModal) window.pulseModal.hide(templatePickerModal); + } + + if (templatePickerOpenButton && templatePickerModal) { + populateTemplateCanvasSizes(); + templatePickerOpenButton.addEventListener('click', function () { + if (templateSelect.disabled) return; + renderTemplatePicker(); + if (window.pulseModal) window.pulseModal.show(templatePickerModal); + }); + templatePickerModal.addEventListener('shown.bs.modal', function () { + if (templatePickerSearch) templatePickerSearch.focus(); + }); + templatePickerModal.addEventListener('hidden.bs.modal', function () { + templatePickerOpenButton.focus(); + }); + if (templatePickerSearch) templatePickerSearch.addEventListener('input', renderTemplatePicker); + if (templatePickerCanvasSize) templatePickerCanvasSize.addEventListener('change', renderTemplatePicker); + if (window.MutationObserver) { + new MutationObserver(syncTemplatePickerSummary).observe(templateSelect, { attributes: true, attributeFilter: ['disabled', 'title'] }); + } + } + function applyBackdropStyle(element, backgroundColor, backgroundImagePath, backgroundGradient) { if (!element) { return; @@ -249,7 +385,24 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed } function hasExistingSlideContent() { - return hasMeaningfulValue(existingContent); + return hasMeaningfulValue(savedExistingContent); + } + + function applyTemplateDefaults(template) { + Object.keys(existingContent).forEach(function (key) { delete existingContent[key]; }); + if (template && Number(template.id) === Number(existingTemplateId)) { + Object.assign(existingContent, savedExistingContent); + } + (template && Array.isArray(template.regions) ? template.regions : []).forEach(function (region) { + var content = region.default_content; + if (typeof content === 'string') { + try { content = JSON.parse(content); } catch (_error) { content = {}; } + } + if (!content || typeof content !== 'object' || Array.isArray(content)) content = {}; + if (region.read_only === true || Number(region.read_only) === 1 || !Object.prototype.hasOwnProperty.call(existingContent, region.region_key)) { + existingContent[region.region_key] = content; + } + }); } function clearImagePreviewUrls() { @@ -557,6 +710,9 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed } function openPreviewPopup() { + if (!getSelectedTemplate()) { + return; + } var popupWidth = 1280; var popupHeight = 900; var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2)); @@ -625,6 +781,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed function renderPreview() { var template = getSelectedTemplate(); + if (openPreviewPopupButton) openPreviewPopupButton.disabled = !template; if (!template) { slidePreviewMeta.textContent = '--'; slidePreviewBackground.style.display = 'none'; @@ -758,16 +915,18 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed clearImagePreviewUrls(); slideFormEditorController.destroyEditors(); var template = getTemplateById(templateSelect.value); - slideLayoutEditor.reset(Number(templateSelect.value) === Number(existingTemplateId) ? existingContent : {}); + applyTemplateDefaults(template); + slideLayoutEditor.reset(Number(templateSelect.value) === Number(existingTemplateId) ? savedExistingContent : {}); if (!template) { templateFields.innerHTML = ''; requestPreviewRender(); return; } - templateFields.innerHTML = template.regions.length ? template.regions.map(function (region) { + var editableRegions = template.regions.filter(function (region) { return !(region.read_only === true || Number(region.read_only) === 1); }); + templateFields.innerHTML = editableRegions.length ? editableRegions.map(function (region) { return getRegionEditorCardHtml(region) || '
Region editor unavailable.
'; - }).join('') : '
This template has no regions.
'; + }).join('') : '
This template has no editable regions.
'; if (typeof window.initJsonTogglePanels === 'function') { window.initJsonTogglePanels(templateFields); } @@ -978,7 +1137,10 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed }); } - templateSelect.addEventListener('change', renderTemplate); + templateSelect.addEventListener('change', function () { + renderTemplate(); + syncTemplatePickerSummary(); + }); templateFields.addEventListener('change', function (event) { var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]'); if (webpageInput) { @@ -1062,6 +1224,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed templateSelect.value = String(existingTemplateId); } renderTemplate(); + syncTemplatePickerSummary(); if (window.ResizeObserver && slidePreviewStage) { var previewResizeObserver = new ResizeObserver(function () { requestPreviewRender(); diff --git a/src/web/public/js/slides/slide-layout-editor.js b/src/web/public/js/slides/slide-layout-editor.js index 056e7c2..bc61425 100644 --- a/src/web/public/js/slides/slide-layout-editor.js +++ b/src/web/public/js/slides/slide-layout-editor.js @@ -69,7 +69,7 @@ export function createSlideLayoutEditor(options) { saveStatus.textContent = 'Saving shared layout...'; var result = await readResponse(await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, - body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, regions: helpers.getTemplateLayoutSnapshot(Object.assign({}, template(), { regions: regions().map(effective) })).regions }) + body: JSON.stringify({ slideId: options.slideId || null, baseline: sharedBaseline, regions: helpers.getTemplateLayoutSnapshot(Object.assign({}, template(), { regions: allRegions().map(effective) })).regions }) })); options.updateTemplate(result.template); var inheritedLayouts = Object.create(null); @@ -109,7 +109,8 @@ export function createSlideLayoutEditor(options) { var current = template(); return { width: Number(current && current.canvas_size_width) || 1920, height: Number(current && current.canvas_size_height) || 1080 }; } - function regions() { return template() && template().regions || []; } + function allRegions() { return template() && template().regions || []; } + function regions() { return allRegions().filter(function (region) { return !(region.read_only === true || Number(region.read_only) === 1); }); } function selected() { return regions().find(function (region) { return region.region_key === selectedKey; }); } @@ -147,7 +148,7 @@ export function createSlideLayoutEditor(options) { } function render() { openButton.disabled = active || !regions().length; - animationButton.disabled = active || !regions().length; + animationButton.disabled = active || !allRegions().length; if (!active || sharedSaving) return; if (!selected()) selectedKey = regions().length ? regions()[0].region_key : ''; var region = selected(); diff --git a/src/web/public/js/templates/template-default-content-editor.js b/src/web/public/js/templates/template-default-content-editor.js new file mode 100644 index 0000000..bddc447 --- /dev/null +++ b/src/web/public/js/templates/template-default-content-editor.js @@ -0,0 +1,233 @@ +import { createSlideFormEditorController } from '/assets/js/slides/slide-form-editor.js'; +import { createSlideFormRegionHelpers } from '/assets/js/slides/slide-form-regions.js'; + +(function () { + var host = document.getElementById('region-default-content-editor-host'); + if (!host) return; + var previewStage = document.getElementById('region-default-content-preview-stage'); + var previewWorkspace = document.querySelector('.template-default-content-workspace'); + var previewFrame = document.getElementById('region-default-content-preview-frame'); + var previewCanvas = document.getElementById('region-default-content-preview-canvas'); + var previewBackground = document.getElementById('region-default-content-preview-background'); + var previewEmpty = document.getElementById('region-default-content-preview-empty'); + var templateRegions = []; + var previewResizeObserver = null; + + var editorData = {}; + try { + var dataElement = document.getElementById('template-region-editor-data'); + editorData = JSON.parse((dataElement && dataElement.textContent) || '{}') || {}; + } catch (_error) { + editorData = {}; + } + try { + var templateDataElement = document.getElementById('template-editor-data'); + templateRegions = JSON.parse((templateDataElement && templateDataElement.textContent) || '[]') || []; + if (!Array.isArray(templateRegions)) templateRegions = []; + } catch (_error) { + templateRegions = []; + } + window.pulseWeatherIconSets = Array.isArray(editorData.weatherIconSets) ? editorData.weatherIconSets : []; + + var existingContent = {}; + var activeRegion = null; + var activeContent = {}; + var editorId = 900000000; + var getRegionTypeModule = function (type) { + return window.pulseRegionTypes && typeof window.pulseRegionTypes.get === 'function' + ? window.pulseRegionTypes.get(type) + : null; + }; + var helpers = createSlideFormRegionHelpers({ + templateFields: host, + existingContent: existingContent, + getRegionTypeModule: getRegionTypeModule, + defaultFontSize: 32, + fontStylesheetHref: editorData.fontLibrary && editorData.fontLibrary.stylesheetHref || '', + fontFamilyFormats: editorData.fontLibrary && editorData.fontLibrary.fontFamilyFormats || '', + rssFeeds: editorData.rssFeeds || [], + apiSources: editorData.apiSources || [], + timetableGroups: editorData.timetableGroups || [], + weatherLocations: editorData.weatherLocations || [], + mediaAssets: editorData.mediaAssets || [], + uploadMaxBytes: editorData.uploadLimits && editorData.uploadLimits.imageMaxBytes, + uploadVideoMaxBytes: editorData.uploadLimits && editorData.uploadLimits.videoMaxBytes, + uploadMaxLabel: editorData.uploadMaxLabel, + uploadVideoMaxLabel: editorData.uploadVideoMaxLabel, + uploadMimeTypes: editorData.uploadLimits && editorData.uploadLimits.allowedMimeTypes || [] + }); + var editor = createSlideFormEditorController({ + templateFields: host, + getRegionTypeModule: getRegionTypeModule, + defaultFontSize: 32, + fontStylesheetHref: editorData.fontLibrary && editorData.fontLibrary.stylesheetHref || '', + fontFamilyFormats: editorData.fontLibrary && editorData.fontLibrary.fontFamilyFormats || '', + imageUploadMaxBytes: editorData.uploadLimits && editorData.uploadLimits.wysiwygImageMaxBytes, + imageUploadContext: 'wysiwyg', + markFormDirty: function () {}, + requestPreviewRender: renderPreview, + getEditorBackgroundColor: function () { + var backgroundColorInput = document.getElementById('background-color'); + return String(backgroundColorInput && backgroundColorInput.value || '#111111'); + } + }); + + function cloneContent(value) { + if (!value || typeof value !== 'object' || Array.isArray(value)) return {}; + return JSON.parse(JSON.stringify(value)); + } + + function normalizeDefaultContent(value) { + var content = value; + if (typeof content === 'string') { + try { content = JSON.parse(content); } catch (_error) { content = {}; } + } + return cloneContent(content); + } + + function cleanContent(value) { + var result = Object.assign({}, activeContent); + var transientFields = new Set([ + 'existingContent', 'sources', 'feeds', 'timetableGroups', 'weatherLocations', 'locations', + 'style', 'sampleDataPreview', 'placeholderChips', 'placeholderFields', 'sourceOptions', 'feedOptions' + ]); + Object.keys(value && typeof value === 'object' ? value : {}).forEach(function (key) { + if (!transientFields.has(key)) result[key] = value[key]; + }); + if (value && value.style && typeof value.style === 'object') { + result.font_family = value.style.font_family || result.font_family; + result.font_size = value.style.font_size || result.font_size; + result.font_color = value.style.font_color || result.font_color; + } + result.type = activeRegion.region_type; + return result; + } + + function renderPreview() { + if (!activeRegion || !previewStage || !previewFrame || !previewCanvas) return; + var card = host.querySelector('[data-region-id="' + activeRegion.id + '"]'); + if (!card) return; + var sourceWidth = Math.max(1, Number((document.getElementById('canvas-width') || {}).value) || 1920); + var sourceHeight = Math.max(1, Number((document.getElementById('canvas-height') || {}).value) || 1080); + var workspaceBounds = (previewWorkspace || previewStage).getBoundingClientRect(); + if (workspaceBounds.width < 2 || workspaceBounds.height < 2) return; + var scale = Math.min((workspaceBounds.width - 2) / sourceWidth, (workspaceBounds.height - 2) / sourceHeight); + if (!Number.isFinite(scale) || scale <= 0) return; + var previewWidth = Math.max(1, Math.round(sourceWidth * scale)); + var previewHeight = Math.max(1, Math.round(sourceHeight * scale)); + previewStage.style.setProperty('--template-preview-ratio', String(sourceWidth / sourceHeight)); + previewStage.style.aspectRatio = sourceWidth + ' / ' + sourceHeight; + previewStage.style.width = previewWidth + 'px'; + previewStage.style.height = previewHeight + 'px'; + previewCanvas.style.width = sourceWidth + 'px'; + previewCanvas.style.height = sourceHeight + 'px'; + previewCanvas.style.transform = 'scale(' + scale + ')'; + previewCanvas.style.transformOrigin = 'top left'; + var backgroundColorInput = document.getElementById('background-color'); + previewCanvas.style.backgroundColor = backgroundColorInput && backgroundColorInput.value || '#111111'; + var gradientInput = document.getElementById('background-gradient'); + try { + var gradient = JSON.parse(gradientInput && gradientInput.value || 'null'); + if (gradient && Array.isArray(gradient.stops) && gradient.stops.length > 1) { + previewCanvas.style.backgroundImage = 'linear-gradient(' + (Number(gradient.angle) || 90) + 'deg,' + gradient.stops.map(function (stop) { + return String(stop.color || '#111111') + ' ' + Math.max(0, Math.min(100, Number(stop.position) || 0)) + '%'; + }).join(',') + ')'; + } else { + previewCanvas.style.backgroundImage = ''; + } + } catch (_error) { + previewCanvas.style.backgroundImage = ''; + } + var imagePathInput = document.getElementById('existing-background-image-path'); + var backgroundPreview = document.getElementById('background-preview'); + var imagePath = String(backgroundPreview && backgroundPreview.src && backgroundPreview.style.display !== 'none' + ? backgroundPreview.src + : imagePathInput && imagePathInput.value || '').trim(); + if (previewBackground) { + previewBackground.hidden = !imagePath; + previewBackground.src = imagePath; + previewBackground.style.width = sourceWidth + 'px'; + previewBackground.style.height = sourceHeight + 'px'; + previewBackground.style.transform = 'scale(' + scale + ')'; + } + + previewCanvas.replaceChildren(); + var hasContent = false; + templateRegions.forEach(function (region) { + var isActiveRegion = region.region_key === activeRegion.region_key; + var displayRegion = isActiveRegion ? activeRegion : region; + var content = isActiveRegion + ? helpers.getPreviewRegionContent(card, activeRegion) + : normalizeDefaultContent(region.default_content); + existingContent[region.region_key] = content; + var context = helpers.buildPreviewRenderContext(displayRegion, isActiveRegion ? card : null); + var module = getRegionTypeModule(displayRegion.region_type); + var html = module && typeof module.renderPreview === 'function' + ? module.renderPreview(displayRegion, context, context) + : helpers.renderPreviewTextRegion(displayRegion, context.value, context.style || {}, 1); + var regionElement = document.createElement('div'); + regionElement.className = 'template-default-content-preview-region'; + regionElement.dataset.regionKey = String(displayRegion.region_key || ''); + regionElement.style.position = 'absolute'; + regionElement.style.left = Math.max(0, Number(displayRegion.x) || 0) + 'px'; + regionElement.style.top = Math.max(0, Number(displayRegion.y) || 0) + 'px'; + regionElement.style.width = Math.max(1, Number(displayRegion.width) || 1) + 'px'; + regionElement.style.height = Math.max(1, Number(displayRegion.height) || 1) + 'px'; + regionElement.style.zIndex = Math.max(1, Number(displayRegion.z_index) || 1); + regionElement.style.overflow = 'hidden'; + regionElement.innerHTML = html || ''; + if (!html) { + regionElement.style.outline = isActiveRegion ? '2px dashed rgba(255,255,255,.9)' : '1px dashed rgba(255,255,255,.45)'; + if (Number(displayRegion.height) >= 24 && Number(displayRegion.width) >= 80) { + var label = document.createElement('span'); + label.className = 'template-default-content-preview-label'; + label.textContent = String(displayRegion.label || displayRegion.region_key || 'Region'); + regionElement.appendChild(label); + } + } + if (html) hasContent = true; + previewCanvas.appendChild(regionElement); + }); + previewFrame.hidden = false; + if (previewEmpty) previewEmpty.hidden = hasContent; + } + + window.pulseTemplateDefaultContentEditor = { + async open(region, content) { + await editor.destroyEditors(); + host.replaceChildren(); + activeContent = cloneContent(content); + activeRegion = Object.assign({}, region, { id: editorId++ }); + templateRegions.forEach(function (templateRegion) { + existingContent[templateRegion.region_key] = normalizeDefaultContent(templateRegion.default_content); + }); + existingContent[activeRegion.region_key] = activeContent; + host.innerHTML = helpers.getRegionEditorCardHtml(activeRegion); + helpers.bindRegionCards(); + await editor.renderEditors(); + renderPreview(); + }, + async read() { + if (!activeRegion) throw new Error('No region editor is open.'); + await editor.syncEditors(); + var card = host.querySelector('[data-region-id="' + activeRegion.id + '"]'); + return cleanContent(helpers.getPreviewRegionContent(card, activeRegion)); + }, + async destroy() { + await editor.destroyEditors(); + host.replaceChildren(); + if (activeRegion) delete existingContent[activeRegion.region_key]; + activeRegion = null; + activeContent = {}; + if (previewCanvas) previewCanvas.replaceChildren(); + if (previewEmpty) previewEmpty.hidden = true; + } + }; + host.addEventListener('input', renderPreview); + host.addEventListener('change', renderPreview); + window.addEventListener('resize', renderPreview); + if (window.ResizeObserver && previewWorkspace) { + previewResizeObserver = new ResizeObserver(renderPreview); + previewResizeObserver.observe(previewWorkspace); + } +}()); \ No newline at end of file diff --git a/src/web/public/js/templates/template-designer-utils.js b/src/web/public/js/templates/template-designer-utils.js index 15a2005..82b45d9 100644 --- a/src/web/public/js/templates/template-designer-utils.js +++ b/src/web/public/js/templates/template-designer-utils.js @@ -263,11 +263,19 @@ function readCard(card) { var name = getRegionName(card); + var defaultContentInput = card.querySelector('[data-region-default-content]'); + var readOnlyInput = card.querySelector('[data-region-read-only]'); + var defaultContent = {}; + if (defaultContentInput) { + try { defaultContent = JSON.parse(defaultContentInput.value || '{}'); } catch (_error) { defaultContent = {}; } + } return { region_key: name, label: name, region_type: card.querySelector('[name="region_type[]"]').value, lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').value), + default_content: defaultContent && typeof defaultContent === 'object' && !Array.isArray(defaultContent) ? defaultContent : {}, + read_only: Boolean(readOnlyInput && readOnlyInput.checked), animation_json: getAnimationConfig(card), x: Number(card.querySelector('[name="region_x[]"]').value || 0), y: Number(card.querySelector('[name="region_y[]"]').value || 0), @@ -287,6 +295,10 @@ } if (values.region_type !== undefined) { card.querySelector('[name="region_type[]"]').value = values.region_type; } if (values.lock_ratio !== undefined) { card.querySelector('[name="region_lock_ratio[]"]').value = normalizeLockRatio(values.lock_ratio); } + var defaultContentInput = card.querySelector('[data-region-default-content]'); + var readOnlyInput = card.querySelector('[data-region-read-only]'); + if (defaultContentInput && values.default_content !== undefined) { defaultContentInput.value = JSON.stringify(values.default_content || {}, null, 2); } + if (readOnlyInput && values.read_only !== undefined) { readOnlyInput.checked = values.read_only === true || values.read_only === 1; } if (values.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); } 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); } diff --git a/src/web/public/js/templates/template-designer.js b/src/web/public/js/templates/template-designer.js index 4335de7..d4b9982 100644 --- a/src/web/public/js/templates/template-designer.js +++ b/src/web/public/js/templates/template-designer.js @@ -55,6 +55,10 @@ var regionAddModal = document.getElementById('region-add-modal'); var regionAddOptions = document.getElementById('region-add-options'); var animationAdvancedModal = document.getElementById('animation-advanced-modal'); + var defaultContentModal = document.getElementById('region-default-content-modal'); + var defaultContentModalTitle = document.getElementById('region-default-content-modal-label'); + var defaultContentEditorHost = document.getElementById('region-default-content-editor-host'); + var defaultContentSaveButton = document.getElementById('region-default-content-save'); var regionCardTemplate = document.getElementById('region-card-template'); var regionsJsonInput = document.getElementById('regions-json'); var templateForm = document.getElementById('template-form'); @@ -63,6 +67,7 @@ var draft = null; var selectedIndex = -1; var activeAnimationCard = null; + var activeDefaultContentCard = null; var overlayRenderFrame = 0; var stageResizeObserver = null; var regionNameValidationToastShown = false; @@ -830,6 +835,65 @@ if (values.z_index !== undefined) { card.querySelector('[name="region_z[]"]').value = Math.max(1, Math.round(Number(values.z_index) || 1)); } } + function readDefaultContent(card) { + var input = card && card.querySelector('[data-region-default-content]'); + if (!input) return {}; + try { + var content = JSON.parse(input.value || '{}'); + return content && typeof content === 'object' && !Array.isArray(content) ? content : {}; + } catch (_error) { + return {}; + } + } + + function openDefaultContentEditor(card) { + if (!defaultContentModal || !card || !window.pulseModal) return; + activeDefaultContentCard = card; + var main = document.querySelector('.app-main'); + var sidebarEdge = window.innerWidth >= 992 && main ? Math.max(0, main.getBoundingClientRect().left) : 0; + defaultContentModal.style.setProperty('--slide-layout-left', sidebarEdge + 'px'); + var region = readCard(card); + var content = readDefaultContent(card); + if (defaultContentModalTitle) defaultContentModalTitle.textContent = 'Default content: ' + (region.region_name || region.region_key || 'Region'); + if (defaultContentEditorHost) defaultContentEditorHost.replaceChildren(); + if (defaultContentSaveButton) defaultContentSaveButton.disabled = true; + if (defaultContentModal) defaultContentModal.dataset.regionType = region.region_type; + window.pulseTemplateDefaultContentEditorContent = { region: region, content: content }; + window.pulseModal.show(defaultContentModal); + } + + if (defaultContentModal) { + defaultContentModal.addEventListener('shown.bs.modal', function () { + var state = window.pulseTemplateDefaultContentEditorContent; + if (!state || !window.pulseTemplateDefaultContentEditor || !defaultContentEditorHost) return; + window.pulseTemplateDefaultContentEditor.open(state.region, state.content, defaultContentEditorHost).then(function () { + if (defaultContentSaveButton) defaultContentSaveButton.disabled = false; + }).catch(function () { + if (typeof showToast === 'function') showToast('The region editor could not be loaded.', 'danger'); + }); + }); + defaultContentModal.addEventListener('hidden.bs.modal', function () { + if (window.pulseTemplateDefaultContentEditor) window.pulseTemplateDefaultContentEditor.destroy(); + window.pulseTemplateDefaultContentEditorContent = null; + }); + } + if (defaultContentSaveButton) { + defaultContentSaveButton.addEventListener('click', async function () { + if (!activeDefaultContentCard || !window.pulseTemplateDefaultContentEditor) return; + try { + var region = readCard(activeDefaultContentCard); + var content = await window.pulseTemplateDefaultContentEditor.read(); + content.type = region.region_type; + writeCard(activeDefaultContentCard, { default_content: content }); + markTemplateFormDirty(); + activeDefaultContentCard = null; + window.pulseModal.hide(defaultContentModal); + } catch (_error) { + if (typeof showToast === 'function') showToast('Unable to read region content.', 'danger'); + } + }); + } + function getOverlayRect() { return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect(); } @@ -1154,6 +1218,14 @@ if (regionLabelInput) { regionLabelInput.value = region.label || region.region_key || ''; } + var defaultContentInput = card.querySelector('[data-region-default-content]'); + if (defaultContentInput) { + defaultContentInput.value = JSON.stringify(region.default_content || {}); + } + var readOnlyInput = card.querySelector('[data-region-read-only]'); + if (readOnlyInput) { + readOnlyInput.checked = region.read_only === true || Number(region.read_only) === 1; + } var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]'); if (regionLockRatioInput) { regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio || (String(region.region_type || '').trim() === 'qr-code' ? '1:1' : '')); @@ -1211,6 +1283,7 @@ var animationPresetInputs = card.querySelectorAll('[data-animation-preset]'); var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]'); var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]'); + var defaultContentButton = card.querySelector('[data-region-default-content-button]'); nameInput.addEventListener('input', function () { regionNameValidationToastShown = false; syncRegionIdentity(card, nameInput.value); @@ -1219,6 +1292,9 @@ renderRegionSidebar(); renderOverlay(); }); + var readOnlyInput = card.querySelector('[data-region-read-only]'); + if (defaultContentButton) defaultContentButton.addEventListener('click', function () { openDefaultContentEditor(card); }); + if (readOnlyInput) readOnlyInput.addEventListener('change', markTemplateFormDirty); Array.prototype.forEach.call(animationPresetInputs, function (input) { input.addEventListener('change', function () { setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value); diff --git a/src/web/routes/admin/content.js b/src/web/routes/admin/content.js index 6d96f2a..9842cf6 100644 --- a/src/web/routes/admin/content.js +++ b/src/web/routes/admin/content.js @@ -54,6 +54,10 @@ module.exports = function registerContentRoutes(app, deps) { region_type: region.region_type, label: region.label, lock_ratio: region.lock_ratio, + default_content: typeof region.default_content === 'string' + ? common.parseJsonSafe(region.default_content) || {} + : region.default_content || {}, + read_only: Boolean(region.read_only), animation_json: animation, x: Number(region.x), y: Number(region.y), @@ -64,8 +68,8 @@ module.exports = function registerContentRoutes(app, deps) { }); } - async function fetchSlideFormData() { - const data = await common.fetchTemplatesData(pool); + async function fetchSlideFormData(includeTemplates) { + const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool); const rssData = await common.fetchRssFeedsData(pool); const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] }; const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] }; @@ -109,6 +113,19 @@ module.exports = function registerContentRoutes(app, deps) { }); } + function pickTemplateRegionEditorData(data) { + return { + rssFeeds: data.rssFeeds || [], + apiSources: data.apiSources || [], + timetableGroups: data.timetableGroups || [], + weatherLocations: data.weatherLocations || [], + weatherIconSets: data.weatherIconSets || [], + mediaAssets: data.mediaAssets || [], + fontLibrary: data.fontLibrary || null, + uploadLimits: data.uploadLimits || {} + }; + } + 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('registerContentRoutes requires the content route dependencies.'); } @@ -693,11 +710,10 @@ module.exports = function registerContentRoutes(app, deps) { app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) { try { - const data = await common.fetchCanvasSizesData(pool); - await syncMediaAssetsFromDirectory(pool, deps.uploadDir); - const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24); - const settings = await fetchAppSettings(pool); - res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, mediaAssets, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES)); + const [data, slideEditorData] = await Promise.all([common.fetchCanvasSizesData(pool), fetchSlideFormData(false)]); + const regionEditorData = pickTemplateRegionEditorData(slideEditorData); + const imageUploadMaxBytes = Number(regionEditorData.uploadLimits.imageMaxBytes) || IMAGE_UPLOAD_MAX_BYTES; + res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, regionEditorData.mediaAssets, imageUploadMaxBytes, regionEditorData)); } catch (error) { next(error); } @@ -724,8 +740,8 @@ module.exports = function registerContentRoutes(app, deps) { for (let i = 0; i < payload.regions.length; i += 1) { const region = payload.regions[i]; await pool.query( - 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] + 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json, read_only, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [result.insertId, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), JSON.stringify(region.default_content || {}), region.read_only ? 1 : 0, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] ); } await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload)); @@ -755,11 +771,10 @@ module.exports = function registerContentRoutes(app, deps) { template.region_usage = await fetchTemplateRegionUsage(template); template.slide_count = (await fetchSlidesByTemplateId(template.id)).length; template.inUse = template.slide_count > 0; - const sizeData = await common.fetchCanvasSizesData(pool, template.canvas_size_id); - await syncMediaAssetsFromDirectory(pool, deps.uploadDir); - const mediaAssets = await fetchMediaAssets(pool); - const settings = await fetchAppSettings(pool); - res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, mediaAssets, Number(settings['uploads.image_max_bytes']) || IMAGE_UPLOAD_MAX_BYTES)); + const [sizeData, slideEditorData] = await Promise.all([common.fetchCanvasSizesData(pool, template.canvas_size_id), fetchSlideFormData(false)]); + const regionEditorData = pickTemplateRegionEditorData(slideEditorData); + const imageUploadMaxBytes = Number(regionEditorData.uploadLimits.imageMaxBytes) || IMAGE_UPLOAD_MAX_BYTES; + res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, regionEditorData.mediaAssets, imageUploadMaxBytes, regionEditorData)); } catch (error) { next(error); } @@ -980,8 +995,8 @@ module.exports = function registerContentRoutes(app, deps) { for (let i = 0; i < payload.regions.length; i += 1) { const region = payload.regions[i]; await pool.query( - 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', - [template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] + 'INSERT INTO c_template_regions (template_id, region_key, region_type, label, lock_ratio, animation_json, default_content_json, read_only, x, y, width, height, z_index, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)', + [template.id, region.region_key, region.region_type, region.label, region.lock_ratio, JSON.stringify(region.animation_json || {}), JSON.stringify(region.default_content || {}), region.read_only ? 1 : 0, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId] ); } await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs))))); diff --git a/src/web/routes/data-sources/rss-feeds/routes.js b/src/web/routes/data-sources/rss-feeds/routes.js index 076d13e..32a309a 100644 --- a/src/web/routes/data-sources/rss-feeds/routes.js +++ b/src/web/routes/data-sources/rss-feeds/routes.js @@ -9,12 +9,13 @@ const { fetchAppSettings } = require('#src/data/app-settings'); async function getDataSourceUsageMaps(pool, common) { const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL'); const rssFeedIds = new Set(); - for (let index = 0; index < slides.length; index += 1) { - const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; + function collectUsage(json) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null; if (!content || typeof content !== 'object') { - continue; + return; } (function walk(value) { @@ -40,6 +41,9 @@ async function getDataSourceUsageMaps(pool, common) { })(content); } + slides.forEach(function (slide) { collectUsage(slide.content_json); }); + templateRegions.forEach(function (region) { collectUsage(region.default_content_json); }); + return rssFeedIds; } @@ -119,32 +123,8 @@ module.exports = function registerRssFeedRoutes(app, deps) { return res.status(404).send('RSS feed not found'); } - const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); - const inUse = (slides || []).some(function (row) { - const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(row.content_json) : null; - if (!content || typeof content !== 'object') { - return false; - } - - const stack = [content]; - while (stack.length) { - const value = stack.pop(); - if (!value || typeof value !== 'object') { - continue; - } - if (Array.isArray(value)) { - value.forEach(function (item) { stack.push(item); }); - continue; - } - if (Object.prototype.hasOwnProperty.call(value, 'feed_id') && Number(value.feed_id) === Number(rssFeed.id)) { - return true; - } - Object.keys(value).forEach(function (key) { - stack.push(value[key]); - }); - } - return false; - }); + const usageIds = await getDataSourceUsageMaps(pool, common); + const inUse = usageIds.has(Number(rssFeed.id)); const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function' ? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id) @@ -317,7 +297,7 @@ module.exports = function registerRssFeedRoutes(app, deps) { } if (await isRssFeedInUse(pool, common, rssFeed.id)) { - return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by one or more slides.')); + return res.redirect('/data-sources/rss-feeds?message=' + encodeURIComponent('This RSS feed is still used by slides or template defaults.')); } const connection = await pool.getConnection(); @@ -337,4 +317,6 @@ module.exports = function registerRssFeedRoutes(app, deps) { next(error); } }); -}; \ No newline at end of file +}; + +module.exports.getDataSourceUsageMaps = getDataSourceUsageMaps; \ No newline at end of file diff --git a/src/web/routes/data-sources/timetables/routes.js b/src/web/routes/data-sources/timetables/routes.js index a4bdecd..3f8dee5 100644 --- a/src/web/routes/data-sources/timetables/routes.js +++ b/src/web/routes/data-sources/timetables/routes.js @@ -8,12 +8,13 @@ const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDup async function getDataSourceUsageMaps(pool, common) { const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL'); const timetableGroupIds = new Set(); - for (let index = 0; index < slides.length; index += 1) { - const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null; + function collectUsage(json) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null; if (!content || typeof content !== 'object') { - continue; + return; } (function walk(value) { @@ -39,6 +40,9 @@ async function getDataSourceUsageMaps(pool, common) { })(content); } + slides.forEach(function (slide) { collectUsage(slide.content_json); }); + templateRegions.forEach(function (region) { collectUsage(region.default_content_json); }); + return timetableGroupIds; } @@ -324,7 +328,7 @@ module.exports = function registerTimetableRoutes(app, deps) { const usageIds = await getDataSourceUsageMaps(pool, common); if (usageIds.has(Number(timetableGroup.id))) { - return res.redirect('/data-sources/timetables?message=' + encodeURIComponent('This timetable group is still used by one or more slides.')); + return res.redirect('/data-sources/timetables?message=' + encodeURIComponent('This timetable group is still used by slides or template defaults.')); } const connection = await pool.getConnection(); @@ -343,4 +347,6 @@ module.exports = function registerTimetableRoutes(app, deps) { next(error); } }); -}; \ No newline at end of file +}; + +module.exports.getDataSourceUsageMaps = getDataSourceUsageMaps; \ No newline at end of file diff --git a/src/web/routes/data-sources/weather.js b/src/web/routes/data-sources/weather.js index 4679f3c..9118bd5 100644 --- a/src/web/routes/data-sources/weather.js +++ b/src/web/routes/data-sources/weather.js @@ -10,9 +10,10 @@ const { buildAuditChanges } = require('#src/data/audit-log'); async function getWeatherLocationUsageIds(pool, common) { const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL'); + const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL'); const weatherLocationIds = new Set(); - slides.forEach(function (slide) { - const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null; + function collectUsage(json) { + const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null; if (!content || typeof content !== 'object') return; (function walk(value) { if (!value || typeof value !== 'object') return; @@ -26,7 +27,9 @@ async function getWeatherLocationUsageIds(pool, common) { } Object.keys(value).forEach(function (key) { walk(value[key]); }); }(content)); - }); + } + slides.forEach(function (slide) { collectUsage(slide.content_json); }); + templateRegions.forEach(function (region) { collectUsage(region.default_content_json); }); return weatherLocationIds; } @@ -189,7 +192,7 @@ module.exports = function registerWeatherRoutes(app, deps) { const location = await common.fetchWeatherLocationById(pool, Number(req.params.id)); if (!location) return res.status(404).send('Weather location not found'); if (await isWeatherLocationInUse(pool, common, location.id)) { - return res.redirect('/data-sources/weather?message=' + encodeURIComponent('This weather location is still used by one or more slides.')); + return res.redirect('/data-sources/weather?message=' + encodeURIComponent('This weather location is still used by slides or template defaults.')); } await pool.query('DELETE FROM i_weather_locations WHERE id = ?', [location.id]); dataSourceTasks.removeRecurringRefresh('weather-location', location.id); @@ -197,4 +200,6 @@ module.exports = function registerWeatherRoutes(app, deps) { res.redirect('/data-sources/weather?message=' + encodeURIComponent('Weather location deleted.')); } catch (error) { next(error); } }); -}; \ No newline at end of file +}; + +module.exports.getWeatherLocationUsageIds = getWeatherLocationUsageIds; \ No newline at end of file diff --git a/src/web/routes/settings/fonts.js b/src/web/routes/settings/fonts.js index fbb7f2f..46f65ba 100644 --- a/src/web/routes/settings/fonts.js +++ b/src/web/routes/settings/fonts.js @@ -25,7 +25,11 @@ async function fetchUsedFontFamilies(pool, fonts) { 'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0', [family] ); - if (Number(rows && rows[0] && rows[0].match_count) > 0) { + const [templateRows] = await pool.query( + 'SELECT COUNT(*) AS match_count FROM c_template_regions WHERE LOCATE(LOWER(?), LOWER(CAST(COALESCE(default_content_json, "") AS CHAR))) > 0', + [family] + ); + if (Number(rows && rows[0] && rows[0].match_count) > 0 || Number(templateRows && templateRows[0] && templateRows[0].match_count) > 0) { usedFamilies.add(family); } } @@ -43,8 +47,12 @@ async function isFontUsed(pool, family) { 'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0', [normalizedFamily] ); + const [templateRows] = await pool.query( + 'SELECT COUNT(*) AS match_count FROM c_template_regions WHERE LOCATE(LOWER(?), LOWER(CAST(COALESCE(default_content_json, "") AS CHAR))) > 0', + [normalizedFamily] + ); - return Number(rows && rows[0] && rows[0].match_count) > 0; + return Number(rows && rows[0] && rows[0].match_count) > 0 || Number(templateRows && templateRows[0] && templateRows[0].match_count) > 0; } async function buildFontsPageData(pool, uploadDir, req, common) { @@ -230,7 +238,7 @@ module.exports = function registerFontRoutes(app, deps) { }); const family = currentFont ? String(currentFont.family || currentFont.name || '').trim() : ''; if (family && await isFontUsed(pool, family)) { - return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font is still used by one or more slides.')); + return res.redirect('/settings/fonts?message=' + encodeURIComponent('That font is still used by slides or template defaults.')); } const removed = await fontLibrary.deleteFontFile(uploadDir, fontId); diff --git a/src/web/routes/settings/weather-icons.js b/src/web/routes/settings/weather-icons.js index f0d5d82..41bfe83 100644 --- a/src/web/routes/settings/weather-icons.js +++ b/src/web/routes/settings/weather-icons.js @@ -5,6 +5,22 @@ const { fetchPlayerRegistrations } = require('#src/data/player-registry'); const { buildPagination } = require('#src/web/lib/pagination'); const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query'); +async function isWeatherIconSetUsed(pool, iconSetId) { + const [slides] = await pool.query('SELECT content_json FROM c_slides'); + const [templateRegions] = await pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL'); + function matches(value) { + let content = value; + if (typeof content === 'string') { + try { content = JSON.parse(content); } catch (_error) { content = null; } + } + if (!content || typeof content !== 'object') return false; + if (!Array.isArray(content) && String(content.weather_icon_set_id || '') === String(iconSetId)) return true; + return (Array.isArray(content) ? content : Object.values(content)).some(matches); + } + return slides.some(function (slide) { return matches(slide.content_json); }) + || templateRegions.some(function (region) { return matches(region.default_content_json); }); +} + module.exports = function registerWeatherIconRoutes(app, deps) { const recordRequestAuditEvent = deps.recordRequestAuditEvent; const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } }); @@ -13,16 +29,22 @@ module.exports = function registerWeatherIconRoutes(app, deps) { }); async function page(req, res, message, status) { const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides'); + const [templateRegions] = await deps.pool.query('SELECT default_content_json FROM c_template_regions WHERE default_content_json IS NOT NULL'); const search = getSearchQuery(req); const sort = getSortQuery(req); const direction = getSortDirectionQuery(req); const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) { - const usageCount = slides.filter(function (slide) { + const slideUsageCount = slides.filter(function (slide) { const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {}; return Object.values(content).some(function (region) { return region && region.weather_icon_set_id === set.id; }); }).length; + const templateUsageCount = templateRegions.filter(function (region) { + const content = typeof region.default_content_json === 'string' ? JSON.parse(region.default_content_json || '{}') : region.default_content_json || {}; + return content && String(content.weather_icon_set_id || '') === String(set.id); + }).length; + const usageCount = slideUsageCount + templateUsageCount; return Object.assign({}, set, { usageCount: usageCount, conditions: library.CONDITIONS.map(function (condition) { return Object.assign({}, condition, { image: set.icons[condition.key], nightImage: (set.nightIcons || {})[condition.key], setId: set.id }); }) }); @@ -97,14 +119,7 @@ module.exports = function registerWeatherIconRoutes(app, deps) { }); app.post('/settings/weather-icons/:id/delete', deps.requirePermission('weather-icons.delete'), async function (req, res, next) { try { - const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides'); - const used = slides.some(function (slide) { - const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {}; - return Object.values(content).some(function (region) { - return region && region.weather_icon_set_id === req.params.id; - }); - }); - if (used) return await page(req, res, 'That icon set is still used by one or more slides.', 400); + if (await isWeatherIconSetUsed(deps.pool, req.params.id)) return await page(req, res, 'That icon set is still used by slides or template defaults.', 400); const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id); if (typeof recordRequestAuditEvent === 'function') { await recordRequestAuditEvent(deps.pool, req, { @@ -117,4 +132,6 @@ module.exports = function registerWeatherIconRoutes(app, deps) { res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.')); } catch (error) { next(error); } }); -}; \ No newline at end of file +}; + +module.exports.isWeatherIconSetUsed = isWeatherIconSetUsed; \ No newline at end of file diff --git a/src/web/routes/signage/templates/add.js b/src/web/routes/signage/templates/add.js index 5f39f7f..9df03cd 100644 --- a/src/web/routes/signage/templates/add.js +++ b/src/web/routes/signage/templates/add.js @@ -3,6 +3,6 @@ const { renderView } = require('#src/web/view'); const { buildTemplateFormViewModel } = require('#src/web/routes/signage/templates/form-view-model'); -module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser, mediaAssets, imageUploadMaxBytes) { - return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false, mediaAssets, imageUploadMaxBytes)); +module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser, mediaAssets, imageUploadMaxBytes, regionEditorData) { + return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false, mediaAssets, imageUploadMaxBytes, regionEditorData)); }; diff --git a/src/web/routes/signage/templates/edit.js b/src/web/routes/signage/templates/edit.js index ec1f934..5cbda50 100644 --- a/src/web/routes/signage/templates/edit.js +++ b/src/web/routes/signage/templates/edit.js @@ -3,6 +3,6 @@ const { renderView } = require('#src/web/view'); const { buildTemplateFormViewModel } = require('#src/web/routes/signage/templates/form-view-model'); -module.exports = function renderTemplateEditPage(template, data, message, currentUser, mediaAssets, imageUploadMaxBytes) { - return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true, mediaAssets, imageUploadMaxBytes)); +module.exports = function renderTemplateEditPage(template, data, message, currentUser, mediaAssets, imageUploadMaxBytes, regionEditorData) { + return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true, mediaAssets, imageUploadMaxBytes, regionEditorData)); }; diff --git a/src/web/routes/signage/templates/form-view-model.js b/src/web/routes/signage/templates/form-view-model.js index 6d219f1..564d4c7 100644 --- a/src/web/routes/signage/templates/form-view-model.js +++ b/src/web/routes/signage/templates/form-view-model.js @@ -43,7 +43,7 @@ function resolveTemplateCanvasSize(template, canvasSizes) { }); } -function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit, mediaAssets, imageUploadMaxBytes) { +function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, isEdit, mediaAssets, imageUploadMaxBytes, regionEditorData) { const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []); const assetVersion = Date.now().toString(36); @@ -65,6 +65,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser, deleteConfirmMessage: isEdit ? 'Delete this template?' : '', canvasSizes: canvasSizes || [], mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [], + regionEditorData: regionEditorData || {}, imageUploadMaxBytes: Number(imageUploadMaxBytes) || 100 * 1024 * 1024, animationPresets: animationPresets, scripts: ['js/lib/modal.js?v=' + assetVersion, 'js/shared/region-layout.js?v=' + assetVersion, 'js/templates/animation-presets.js?v=' + assetVersion, 'vendor/qr-code-styling/qr-code-styling-loader.js?v=' + assetVersion].concat(getRegionEditorScripts(assetVersion), ['js/templates/template-designer-utils.js?v=' + assetVersion, 'js/shared/region-animation-controls.js?v=' + assetVersion, 'js/templates/template-designer.js?v=' + assetVersion]) diff --git a/src/web/views/shared/media-picker-modal.hbs b/src/web/views/shared/media-picker-modal.hbs index b32c65f..d074c46 100644 --- a/src/web/views/shared/media-picker-modal.hbs +++ b/src/web/views/shared/media-picker-modal.hbs @@ -51,7 +51,7 @@ {{/modal-shell}} diff --git a/src/web/views/signage/playlists/form.hbs b/src/web/views/signage/playlists/form.hbs index 132aa53..dee947c 100644 --- a/src/web/views/signage/playlists/form.hbs +++ b/src/web/views/signage/playlists/form.hbs @@ -115,8 +115,8 @@ {{/modal-shell}} \ No newline at end of file diff --git a/src/web/views/signage/playlists/schedule-rule-card.hbs b/src/web/views/signage/playlists/schedule-rule-card.hbs index 520d0aa..1b7f508 100644 --- a/src/web/views/signage/playlists/schedule-rule-card.hbs +++ b/src/web/views/signage/playlists/schedule-rule-card.hbs @@ -7,7 +7,8 @@
diff --git a/src/web/views/signage/playlists/slide-config.hbs b/src/web/views/signage/playlists/slide-config.hbs index 2d27126..8a920a7 100644 --- a/src/web/views/signage/playlists/slide-config.hbs +++ b/src/web/views/signage/playlists/slide-config.hbs @@ -26,11 +26,9 @@ {{> signage/playlists/schedule-rule-card ruleIndex=1 ruleSummary="" startDateTimeValue="" endDateTimeValue="" startTimeValue="" endTimeValue="" dayOptions=templateDayOptions isCollapsed=true}} - diff --git a/src/web/views/signage/slides/form.hbs b/src/web/views/signage/slides/form.hbs index c3ab74e..58118b4 100644 --- a/src/web/views/signage/slides/form.hbs +++ b/src/web/views/signage/slides/form.hbs @@ -20,8 +20,12 @@
- - {{#each templates}} @@ -79,6 +83,28 @@
+{{#> modal-shell modalId="template-picker-modal" modalLabelId="template-picker-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable template-picker-dialog"}} + + + +{{/modal-shell}} + {{#> modal-shell modalId="slide-layout-modal" modalLabelId="slide-layout-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog"}} @@ -179,5 +184,33 @@ {{> media-picker-modal mediaAssets=mediaAssets}} +{{#> modal-shell modalId="region-default-content-modal" modalLabelId="region-default-content-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable template-default-content-dialog"}} + + + +{{/modal-shell}} + + + + \ No newline at end of file diff --git a/test/action-buttons.test.js b/test/action-buttons.test.js index 7f33e0d..b2fde0c 100644 --- a/test/action-buttons.test.js +++ b/test/action-buttons.test.js @@ -1,6 +1,7 @@ const test = require('node:test'); const assert = require('node:assert/strict'); const path = require('node:path'); +const fs = require('node:fs'); const Handlebars = require('handlebars'); const { registerHandlebars } = require('../src/web/handlebars'); @@ -69,4 +70,23 @@ test('save action buttons keep the new action wired for create pages', () => { assert.match(html, /name="save_action" value="new">Save and New<\/button>/); assert.match(html, /name="save_action" value="close">Save and Close<\/button>/); +}); + +test('playlist slide picker follows confirm then cancel button conventions', () => { + const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'playlists', 'form.hbs'), 'utf8'); + + assert.match(source, /