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 @@
-
-
+{{#> modal-shell modalId="template-picker-modal" modalLabelId="template-picker-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable template-picker-dialog"}}
+
+
+
+
+
+
+
+
+
+
+
+
No templates match these filters.
+
+
+{{/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"}}
+
+
+
+
+
+
+
![]()
+
+
+
+ No content yet. The preview shows the region's template position.
+
+
+
+{{/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, /