Release v2.18.0: template default content
This commit is contained in:
1 parent
48e8f3eb4b
commit
24f1603b43
54 files changed
+1421
-151
No files matched your search
@@ -2,6 +2,13 @@
|
|||||||
|
|
||||||
All notable changes to this project will be documented in this file.
|
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
|
## 2.17.5 - 2026-10-23
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-player",
|
"name": "pulse-signage-player",
|
||||||
"version": "2.17.5",
|
"version": "2.18.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage player application bundle",
|
"description": "Pulse Signage player application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage-web",
|
"name": "pulse-signage-web",
|
||||||
"version": "2.17.5",
|
"version": "2.18.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage web and bridge application bundle",
|
"description": "Pulse Signage web and bridge application bundle",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
@@ -106,7 +106,7 @@ Tables generally use a numeric auto-increment `id` primary key. The relationship
|
|||||||
|
|
||||||
### `c_template_regions`
|
### `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:
|
- Foreign key:
|
||||||
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
|
- `template_id` -> `c_templates.id` with `ON DELETE CASCADE`
|
||||||
|
|
||||||
|
|||||||
@@ -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 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
|
#### Region Types
|
||||||
|
|
||||||
The available region types cover fixed content, uploaded media, live data, and external content:
|
The available region types cover fixed content, uploaded media, live data, and external content:
|
||||||
|
|||||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.5",
|
"version": "2.18.0",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.5",
|
"version": "2.18.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"animate.css": "^4.1.1",
|
"animate.css": "^4.1.1",
|
||||||
"bootstrap-icons": "1.13.1",
|
"bootstrap-icons": "1.13.1",
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "pulse-signage",
|
"name": "pulse-signage",
|
||||||
"version": "2.17.5",
|
"version": "2.18.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"description": "Pulse Signage application with MySQL and media storage",
|
"description": "Pulse Signage application with MySQL and media storage",
|
||||||
"engines": {
|
"engines": {
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@ async function fetchAdminData(pool) {
|
|||||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY st.id DESC
|
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(`
|
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 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
|
(SELECT COUNT(DISTINCT ps.playlist_id) FROM c_playlist_slides ps WHERE ps.slide_id = s.id) AS playlist_count
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ async function fetchApiGroupsPage(pool, page, pageSize, search, sort, direction)
|
|||||||
|
|
||||||
async function getApiRequestUsage(pool) {
|
async function getApiRequestUsage(pool) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
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();
|
const ids = new Set();
|
||||||
function walk(value) {
|
function walk(value) {
|
||||||
if (!value || typeof value !== 'object') return;
|
if (!value || typeof value !== 'object') return;
|
||||||
@@ -72,6 +73,7 @@ async function getApiRequestUsage(pool) {
|
|||||||
Object.values(value).forEach(walk);
|
Object.values(value).forEach(walk);
|
||||||
}
|
}
|
||||||
slides.forEach(function (slide) { walk(parseJsonSafe(slide.content_json)); });
|
slides.forEach(function (slide) { walk(parseJsonSafe(slide.content_json)); });
|
||||||
|
templateRegions.forEach(function (region) { walk(parseJsonSafe(region.default_content_json)); });
|
||||||
return ids;
|
return ids;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,7 +97,7 @@ async function saveApiGroup(pool, id, payload, actorId, canDelete) {
|
|||||||
if (removed.length) {
|
if (removed.length) {
|
||||||
if (!canDelete) throw validationError('You do not have permission to remove API requests.', 403);
|
if (!canDelete) throw validationError('You do not have permission to remove API requests.', 403);
|
||||||
const usage = await getApiRequestUsage(connection);
|
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]; }));
|
const values = [payload.name].concat(API_AUTH_FIELDS.map(function (field) { return payload.auth[field] === undefined ? null : payload.auth[field]; }));
|
||||||
if (id) {
|
if (id) {
|
||||||
|
|||||||
@@ -309,6 +309,12 @@ async function getRssFeedItemCount(pool, feedId) {
|
|||||||
async function buildTemplateContent(pool, template, body, filesByField, existingContent) {
|
async function buildTemplateContent(pool, template, body, filesByField, existingContent) {
|
||||||
const content = {};
|
const content = {};
|
||||||
for (const region of template.regions) {
|
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') {
|
if (region.region_type === 'image') {
|
||||||
const uploaded = filesByField[`region_image_${region.id}`];
|
const uploaded = filesByField[`region_image_${region.id}`];
|
||||||
const existing = body[`existing_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 };
|
const canvas = { width: template.canvas_size_width, height: template.canvas_size_height };
|
||||||
for (const region of template.regions) {
|
for (const region of template.regions) {
|
||||||
|
if (region.read_only === true || Number(region.read_only) === 1) continue;
|
||||||
const key = region.region_key;
|
const key = region.region_key;
|
||||||
const value = layouts ? (Object.hasOwn(layouts, key) ? layouts[key] : null)
|
const value = layouts ? (Object.hasOwn(layouts, key) ? layouts[key] : null)
|
||||||
: Number(existingSlide && existingSlide.template_id) === template.id && existingContent[key] && existingContent[key].layout;
|
: Number(existingSlide && existingSlide.template_id) === template.id && existingContent[key] && existingContent[key].layout;
|
||||||
|
|||||||
+27
-4
@@ -67,6 +67,25 @@ function normalizeTemplateRegionLockRatio(value) {
|
|||||||
return rawRatio.replace(/\s+/g, '');
|
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) {
|
function normalizeTemplateRegionAnimation(value) {
|
||||||
if (animationPresets && typeof animationPresets.normalize === 'function') {
|
if (animationPresets && typeof animationPresets.normalize === 'function') {
|
||||||
return animationPresets.normalize(value);
|
return animationPresets.normalize(value);
|
||||||
@@ -156,8 +175,8 @@ async function fetchTemplateById(pool, id) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const template = templates[0];
|
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]);
|
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;
|
template.regions = regions.map(normalizeFetchedTemplateRegion);
|
||||||
return template;
|
return template;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,8 +188,8 @@ async function fetchTemplatesData(pool) {
|
|||||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
ORDER BY st.id DESC
|
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');
|
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 };
|
return { templates, templateRegions: templateRegions.map(normalizeFetchedTemplateRegion) };
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractTemplateRegions(body) {
|
function extractTemplateRegions(body) {
|
||||||
@@ -186,6 +205,8 @@ function extractTemplateRegions(body) {
|
|||||||
region_type: regionType,
|
region_type: regionType,
|
||||||
label: regionName,
|
label: regionName,
|
||||||
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
||||||
|
default_content: normalizeTemplateRegionDefaultContent(region.default_content),
|
||||||
|
read_only: normalizeTemplateRegionReadOnly(region.read_only),
|
||||||
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
||||||
x: Number(region.x || 0),
|
x: Number(region.x || 0),
|
||||||
y: Number(region.y || 0),
|
y: Number(region.y || 0),
|
||||||
@@ -222,6 +243,8 @@ function extractTemplateRegions(body) {
|
|||||||
region_type: regionType,
|
region_type: regionType,
|
||||||
label: name,
|
label: name,
|
||||||
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
||||||
|
default_content: {},
|
||||||
|
read_only: false,
|
||||||
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
|
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
|
||||||
x: Number(xs[i] || 0),
|
x: Number(xs[i] || 0),
|
||||||
y: Number(ys[i] || 0),
|
y: Number(ys[i] || 0),
|
||||||
|
|||||||
@@ -79,6 +79,8 @@ async function ensureSchema(pool, options) {
|
|||||||
font_family VARCHAR(100) NULL,
|
font_family VARCHAR(100) NULL,
|
||||||
lock_ratio VARCHAR(20) NULL,
|
lock_ratio VARCHAR(20) NULL,
|
||||||
animation_json JSON NULL,
|
animation_json JSON NULL,
|
||||||
|
default_content_json JSON NULL,
|
||||||
|
read_only TINYINT(1) NOT NULL DEFAULT 0,
|
||||||
x INT NOT NULL DEFAULT 0,
|
x INT NOT NULL DEFAULT 0,
|
||||||
y INT NOT NULL DEFAULT 0,
|
y INT NOT NULL DEFAULT 0,
|
||||||
width INT NOT NULL DEFAULT 100,
|
width INT NOT NULL DEFAULT 100,
|
||||||
|
|||||||
@@ -761,6 +761,14 @@ const VERSIONED_MIGRATIONS = [
|
|||||||
await ensureIndex(pool, 'i_api_source_groups', 'idx_api_source_groups_modified_at', 'modified_at');
|
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');
|
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');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
+13
-3
@@ -245,7 +245,7 @@ function createPlayerPlaylistService(options) {
|
|||||||
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
||||||
WHERE st.id IN (?)
|
WHERE st.id IN (?)
|
||||||
`, [templateIds]);
|
`, [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) {
|
templateRows.forEach(function (template) {
|
||||||
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
template.regions = regionRows.filter(function (region) { return region.template_id === template.id; });
|
||||||
templatesById[template.id] = template;
|
templatesById[template.id] = template;
|
||||||
@@ -279,10 +279,21 @@ function createPlayerPlaylistService(options) {
|
|||||||
|
|
||||||
const slides = await Promise.all(slideRows.map(async function (slide) {
|
const slides = await Promise.all(slideRows.map(async function (slide) {
|
||||||
const storedDuration = Number(slide.duration_seconds || 0);
|
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 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 videoCacheBust = String(slide.modified_at || slide.content_json || slide.id || '');
|
||||||
const content = common.parseJsonSafe(slide.content_json) || {};
|
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 = [];
|
const qrBackfillTasks = [];
|
||||||
Object.keys(content).forEach(function (key) {
|
Object.keys(content).forEach(function (key) {
|
||||||
const region = content[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
|
const groupScheduleRules = slide.group_schedule_rules_json
|
||||||
? common.parseJsonSafe(slide.group_schedule_rules_json) || []
|
? common.parseJsonSafe(slide.group_schedule_rules_json) || []
|
||||||
: [];
|
: [];
|
||||||
|
|||||||
@@ -257,7 +257,15 @@ async function countMediaAssetReferences(pool, mediaPath) {
|
|||||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||||
[normalizedPath]
|
[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) {
|
async function publishMediaAssets(pool, mediaPaths) {
|
||||||
|
|||||||
@@ -69,8 +69,15 @@ function getThumbnailCanvasSize(slide) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getRegionContent(slide, region) {
|
function getRegionContent(slide, region) {
|
||||||
const content = slide && slide.content && slide.content[region.region_key] ? slide.content[region.region_key] : {};
|
const saved = slide && slide.content && slide.content[region.region_key];
|
||||||
return content && typeof content === 'object' ? content : { value: content };
|
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) {
|
function buildThumbnailRegionStyle(region, canvasWidth, canvasHeight) {
|
||||||
|
|||||||
@@ -348,6 +348,9 @@ function createUploadSyncService(options) {
|
|||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
collectUploadReferencesFromValue(template.background_image_path, refs);
|
collectUploadReferencesFromValue(template.background_image_path, refs);
|
||||||
|
(Array.isArray(template.regions) ? template.regions : []).forEach(function (region) {
|
||||||
|
collectUploadReferencesFromValue(region && region.default_content, refs);
|
||||||
|
});
|
||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -358,6 +361,9 @@ function createUploadSyncService(options) {
|
|||||||
}
|
}
|
||||||
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
|
collectUploadReferencesFromValue(common.parseJsonSafe(payload.contentJson), refs);
|
||||||
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
|
collectUploadReferencesFromValue(payload.backgroundImagePath, refs);
|
||||||
|
(Array.isArray(payload.regions) ? payload.regions : []).forEach(function (region) {
|
||||||
|
collectUploadReferencesFromValue(region && region.default_content, refs);
|
||||||
|
});
|
||||||
return refs;
|
return refs;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -376,7 +382,12 @@ function createUploadSyncService(options) {
|
|||||||
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
'SELECT COUNT(*) AS ref_count FROM c_templates WHERE background_image_path = ?',
|
||||||
[uploadPath]
|
[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) {
|
async function uploadFileExists(uploadDir, uploadPath) {
|
||||||
|
|||||||
@@ -2626,7 +2626,8 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#slide-layout-modal,
|
#slide-layout-modal,
|
||||||
#slide-animation-modal {
|
#slide-animation-modal,
|
||||||
|
#region-default-content-modal {
|
||||||
left: var(--slide-layout-left, 0px);
|
left: var(--slide-layout-left, 0px);
|
||||||
width: calc(100% - 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;
|
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 {
|
.slide-layout-dialog .modal-content {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
@@ -2831,12 +3015,26 @@ html[data-bs-theme='dark'] .template-field-card .tox .tox-collection {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 767.98px) {
|
@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 {
|
.slide-layout-dialog {
|
||||||
width: calc(100% - 1rem);
|
width: calc(100% - 1rem);
|
||||||
height: calc(100dvh - 2rem);
|
height: calc(100dvh - 2rem);
|
||||||
margin: 1rem auto;
|
margin: 1rem auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.template-default-content-dialog {
|
||||||
|
width: calc(100% - 1rem);
|
||||||
|
height: calc(100dvh - 2rem);
|
||||||
|
margin: 1rem auto;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-layout-body {
|
.slide-layout-body {
|
||||||
grid-template-columns: minmax(0, 1fr);
|
grid-template-columns: minmax(0, 1fr);
|
||||||
grid-template-rows: minmax(14rem, 1fr) auto;
|
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 {
|
.slide-layout-properties {
|
||||||
overflow: visible;
|
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 */
|
/* 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;
|
scrollbar-gutter: stable both-edges;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.slide-schedule-card > form > .card-footer::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
.slide-schedule-content .schedule-rule-card .card-header {
|
.slide-schedule-content .schedule-rule-card .card-header {
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
var activeButton = null;
|
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 items = Array.prototype.slice.call(modal.querySelectorAll('[data-media-picker-item]'));
|
||||||
var grid = modal.querySelector('[data-media-picker-grid]');
|
var grid = modal.querySelector('[data-media-picker-grid]');
|
||||||
var search = modal.querySelector('[data-media-picker-search]');
|
var search = modal.querySelector('[data-media-picker-search]');
|
||||||
@@ -90,6 +93,27 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function showModal() {
|
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') {
|
if (window.pulseModal && typeof window.pulseModal.show === 'function') {
|
||||||
window.pulseModal.show(modal);
|
window.pulseModal.show(modal);
|
||||||
} else if (window.bootstrap && window.bootstrap.Modal) {
|
} else if (window.bootstrap && window.bootstrap.Modal) {
|
||||||
@@ -431,5 +455,13 @@
|
|||||||
currentPath = '';
|
currentPath = '';
|
||||||
pendingPath = '';
|
pendingPath = '';
|
||||||
pendingType = '';
|
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;
|
||||||
});
|
});
|
||||||
})();
|
})();
|
||||||
@@ -1127,7 +1127,8 @@
|
|||||||
'<div class="d-flex align-items-center gap-4 ms-auto flex-shrink-0">' +
|
'<div class="d-flex align-items-center gap-4 ms-auto flex-shrink-0">' +
|
||||||
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove-schedule-rule data-confirm-message="Remove this rule?">Remove</button>' +
|
'<button type="button" class="btn btn-sm btn-outline-danger" data-remove-schedule-rule data-confirm-message="Remove this rule?">Remove</button>' +
|
||||||
'<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Collapse rule ' + String(ruleIndex) + '" title="Collapse rule ' + String(ruleIndex) + '">' +
|
'<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-label="Collapse rule ' + String(ruleIndex) + '" title="Collapse rule ' + String(ruleIndex) + '">' +
|
||||||
'<i class="bi ' + (isCollapsed ? 'bi-plus-lg' : 'bi-dash-lg') + '" aria-hidden="true" data-schedule-rule-collapse-icon></i>' +
|
'<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>' +
|
||||||
|
'<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>' +
|
||||||
'<span class="visually-hidden">Collapse rule ' + String(ruleIndex) + '</span>' +
|
'<span class="visually-hidden">Collapse rule ' + String(ruleIndex) + '</span>' +
|
||||||
'</button>' +
|
'</button>' +
|
||||||
'</div>' +
|
'</div>' +
|
||||||
@@ -1330,13 +1331,8 @@
|
|||||||
return card ? card.querySelector('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null;
|
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) {
|
function syncRuleCollapseButtonState(card) {
|
||||||
var button = getCollapseRuleButton(card);
|
var button = getCollapseRuleButton(card);
|
||||||
var icon = getCollapseRuleIcon(card);
|
|
||||||
var numberNode = getRuleNumberNode(card);
|
var numberNode = getRuleNumberNode(card);
|
||||||
var ruleIndex = numberNode ? String(numberNode.textContent || '') : '';
|
var ruleIndex = numberNode ? String(numberNode.textContent || '') : '';
|
||||||
var isCollapsed = Boolean(card && card.classList && card.classList.contains('collapsed-card'));
|
var isCollapsed = Boolean(card && card.classList && card.classList.contains('collapsed-card'));
|
||||||
@@ -1350,11 +1346,6 @@
|
|||||||
button.setAttribute('aria-label', label);
|
button.setAttribute('aria-label', label);
|
||||||
button.setAttribute('title', 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');
|
var visibleLabel = button.querySelector('.visually-hidden');
|
||||||
if (visibleLabel) {
|
if (visibleLabel) {
|
||||||
visibleLabel.textContent = label;
|
visibleLabel.textContent = label;
|
||||||
@@ -1742,15 +1733,23 @@
|
|||||||
handleCardChange(card);
|
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) {
|
scheduleRuleList.addEventListener('click', function (event) {
|
||||||
var removeButton = event.target && event.target.closest ? event.target.closest('[data-remove-schedule-rule]') : null;
|
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;
|
var collapseButton = event.target && event.target.closest ? event.target.closest('[data-schedule-rule-collapse], [data-lte-toggle="card-collapse"]') : null;
|
||||||
|
|
||||||
if (collapseButton) {
|
if (collapseButton) {
|
||||||
window.setTimeout(function () {
|
|
||||||
syncRuleCollapseButtonState(collapseButton.closest('[data-schedule-rule-card]'));
|
|
||||||
persistScheduleDraft();
|
|
||||||
}, 0);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,10 +25,11 @@ export function createSlideAnimationEditor(options) {
|
|||||||
var nextSibling = null;
|
var nextSibling = null;
|
||||||
|
|
||||||
function template() { return options.getTemplate(); }
|
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 selectedKey() { return regionSelect.value; }
|
||||||
function getConfig(regionKey) {
|
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)
|
return Object.prototype.hasOwnProperty.call(draft, regionKey)
|
||||||
? draft[regionKey]
|
? draft[regionKey]
|
||||||
: region && region.animation_json || '{}';
|
: region && region.animation_json || '{}';
|
||||||
@@ -49,7 +50,17 @@ export function createSlideAnimationEditor(options) {
|
|||||||
function renderRegion() {
|
function renderRegion() {
|
||||||
var key = selectedKey();
|
var key = selectedKey();
|
||||||
var region = regions().find(function (item) { return item.region_key === key; });
|
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));
|
controls.write(card, getConfig(key));
|
||||||
updatePreviewButtons();
|
updatePreviewButtons();
|
||||||
updateResetButtons();
|
updateResetButtons();
|
||||||
@@ -87,8 +98,8 @@ export function createSlideAnimationEditor(options) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
saveStatus.textContent = 'Saving shared animations...';
|
saveStatus.textContent = 'Saving shared animations...';
|
||||||
var animations = regions().map(function (region) {
|
var animations = allRegions().map(function (region) {
|
||||||
return { region_key: region.region_key, animation_json: getConfig(region.region_key) };
|
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, {
|
var result = await readResponse(await fetch(url, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
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' };
|
var modeKeys = { intro: 'intro', outro: 'outro', loop: 'loop' };
|
||||||
previewButtons.forEach(function (button) {
|
previewButtons.forEach(function (button) {
|
||||||
var mode = button.dataset.slideAnimationPreview;
|
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));
|
var config = window.templateDesignerUtils.normalizeAnimationConfig(getConfig(region.region_key));
|
||||||
return window.templateAnimationPresets.normalize(config[modeKeys[mode]].preset) !== 'none';
|
return window.templateAnimationPresets.normalize(config[modeKeys[mode]].preset) !== 'none';
|
||||||
});
|
});
|
||||||
@@ -184,7 +195,7 @@ export function createSlideAnimationEditor(options) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
openButton.addEventListener('click', function () {
|
openButton.addEventListener('click', function () {
|
||||||
if (!regions().length || !window.pulseModal) return;
|
if (!allRegions().length || !window.pulseModal) return;
|
||||||
regionSelect.replaceChildren();
|
regionSelect.replaceChildren();
|
||||||
regions().forEach(function (region) {
|
regions().forEach(function (region) {
|
||||||
var option = document.createElement('option');
|
var option = document.createElement('option');
|
||||||
@@ -296,5 +307,5 @@ export function createSlideAnimationEditor(options) {
|
|||||||
window.pulseModal.hide(modal);
|
window.pulseModal.hide(modal);
|
||||||
});
|
});
|
||||||
|
|
||||||
return { refresh: function () { openButton.disabled = active || !regions().length; } };
|
return { refresh: function () { openButton.disabled = active || !allRegions().length; } };
|
||||||
}
|
}
|
||||||
@@ -28,8 +28,16 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
|||||||
window.pulseWeatherIconSets = Array.isArray(slideEditorData.weatherIconSets) ? slideEditorData.weatherIconSets : [];
|
window.pulseWeatherIconSets = Array.isArray(slideEditorData.weatherIconSets) ? slideEditorData.weatherIconSets : [];
|
||||||
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
|
var fontStylesheetHref = String(slideEditorData.fontStylesheetHref || '').trim();
|
||||||
var existingTemplateId = slideEditorData.existingTemplateId !== undefined ? slideEditorData.existingTemplateId : null;
|
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 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 templateFields = document.getElementById('template-fields');
|
||||||
var slideForm = document.getElementById('slide-form');
|
var slideForm = document.getElementById('slide-form');
|
||||||
var slideEditorShell = document.querySelector('.slide-editor-shell');
|
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;
|
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) {
|
function applyBackdropStyle(element, backgroundColor, backgroundImagePath, backgroundGradient) {
|
||||||
if (!element) {
|
if (!element) {
|
||||||
return;
|
return;
|
||||||
@@ -249,7 +385,24 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
|||||||
}
|
}
|
||||||
|
|
||||||
function hasExistingSlideContent() {
|
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() {
|
function clearImagePreviewUrls() {
|
||||||
@@ -557,6 +710,9 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
|||||||
}
|
}
|
||||||
|
|
||||||
function openPreviewPopup() {
|
function openPreviewPopup() {
|
||||||
|
if (!getSelectedTemplate()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
var popupWidth = 1280;
|
var popupWidth = 1280;
|
||||||
var popupHeight = 900;
|
var popupHeight = 900;
|
||||||
var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2));
|
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() {
|
function renderPreview() {
|
||||||
var template = getSelectedTemplate();
|
var template = getSelectedTemplate();
|
||||||
|
if (openPreviewPopupButton) openPreviewPopupButton.disabled = !template;
|
||||||
if (!template) {
|
if (!template) {
|
||||||
slidePreviewMeta.textContent = '--';
|
slidePreviewMeta.textContent = '--';
|
||||||
slidePreviewBackground.style.display = 'none';
|
slidePreviewBackground.style.display = 'none';
|
||||||
@@ -758,16 +915,18 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
|||||||
clearImagePreviewUrls();
|
clearImagePreviewUrls();
|
||||||
slideFormEditorController.destroyEditors();
|
slideFormEditorController.destroyEditors();
|
||||||
var template = getTemplateById(templateSelect.value);
|
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) {
|
if (!template) {
|
||||||
templateFields.innerHTML = '';
|
templateFields.innerHTML = '';
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
return;
|
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) || '<div class="muted">Region editor unavailable.</div>';
|
return getRegionEditorCardHtml(region) || '<div class="muted">Region editor unavailable.</div>';
|
||||||
}).join('') : '<div class="muted">This template has no regions.</div>';
|
}).join('') : '<div class="muted">This template has no editable regions.</div>';
|
||||||
if (typeof window.initJsonTogglePanels === 'function') {
|
if (typeof window.initJsonTogglePanels === 'function') {
|
||||||
window.initJsonTogglePanels(templateFields);
|
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) {
|
templateFields.addEventListener('change', function (event) {
|
||||||
var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]');
|
var webpageInput = event.target && typeof event.target.matches === 'function' && event.target.matches('input[type="url"][name^="region_webpage_"]');
|
||||||
if (webpageInput) {
|
if (webpageInput) {
|
||||||
@@ -1062,6 +1224,7 @@ import { createSlideAnimationEditor } from '/assets/js/slides/slide-animation-ed
|
|||||||
templateSelect.value = String(existingTemplateId);
|
templateSelect.value = String(existingTemplateId);
|
||||||
}
|
}
|
||||||
renderTemplate();
|
renderTemplate();
|
||||||
|
syncTemplatePickerSummary();
|
||||||
if (window.ResizeObserver && slidePreviewStage) {
|
if (window.ResizeObserver && slidePreviewStage) {
|
||||||
var previewResizeObserver = new ResizeObserver(function () {
|
var previewResizeObserver = new ResizeObserver(function () {
|
||||||
requestPreviewRender();
|
requestPreviewRender();
|
||||||
|
|||||||
@@ -69,7 +69,7 @@ export function createSlideLayoutEditor(options) {
|
|||||||
saveStatus.textContent = 'Saving shared layout...';
|
saveStatus.textContent = 'Saving shared layout...';
|
||||||
var result = await readResponse(await fetch(url, {
|
var result = await readResponse(await fetch(url, {
|
||||||
method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
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);
|
options.updateTemplate(result.template);
|
||||||
var inheritedLayouts = Object.create(null);
|
var inheritedLayouts = Object.create(null);
|
||||||
@@ -109,7 +109,8 @@ export function createSlideLayoutEditor(options) {
|
|||||||
var current = template();
|
var current = template();
|
||||||
return { width: Number(current && current.canvas_size_width) || 1920, height: Number(current && current.canvas_size_height) || 1080 };
|
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() {
|
function selected() {
|
||||||
return regions().find(function (region) { return region.region_key === selectedKey; });
|
return regions().find(function (region) { return region.region_key === selectedKey; });
|
||||||
}
|
}
|
||||||
@@ -147,7 +148,7 @@ export function createSlideLayoutEditor(options) {
|
|||||||
}
|
}
|
||||||
function render() {
|
function render() {
|
||||||
openButton.disabled = active || !regions().length;
|
openButton.disabled = active || !regions().length;
|
||||||
animationButton.disabled = active || !regions().length;
|
animationButton.disabled = active || !allRegions().length;
|
||||||
if (!active || sharedSaving) return;
|
if (!active || sharedSaving) return;
|
||||||
if (!selected()) selectedKey = regions().length ? regions()[0].region_key : '';
|
if (!selected()) selectedKey = regions().length ? regions()[0].region_key : '';
|
||||||
var region = selected();
|
var region = selected();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}());
|
||||||
@@ -263,11 +263,19 @@
|
|||||||
|
|
||||||
function readCard(card) {
|
function readCard(card) {
|
||||||
var name = getRegionName(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 {
|
return {
|
||||||
region_key: name,
|
region_key: name,
|
||||||
label: name,
|
label: name,
|
||||||
region_type: card.querySelector('[name="region_type[]"]').value,
|
region_type: card.querySelector('[name="region_type[]"]').value,
|
||||||
lock_ratio: normalizeLockRatio(card.querySelector('[name="region_lock_ratio[]"]').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),
|
animation_json: getAnimationConfig(card),
|
||||||
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
x: Number(card.querySelector('[name="region_x[]"]').value || 0),
|
||||||
y: Number(card.querySelector('[name="region_y[]"]').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.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); }
|
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.animation_json !== undefined) { writeAnimationConfig(card, values.animation_json); }
|
||||||
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
if (values.x !== undefined) { card.querySelector('[name="region_x[]"]').value = Math.round(values.x); }
|
||||||
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
if (values.y !== undefined) { card.querySelector('[name="region_y[]"]').value = Math.round(values.y); }
|
||||||
|
|||||||
@@ -55,6 +55,10 @@
|
|||||||
var regionAddModal = document.getElementById('region-add-modal');
|
var regionAddModal = document.getElementById('region-add-modal');
|
||||||
var regionAddOptions = document.getElementById('region-add-options');
|
var regionAddOptions = document.getElementById('region-add-options');
|
||||||
var animationAdvancedModal = document.getElementById('animation-advanced-modal');
|
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 regionCardTemplate = document.getElementById('region-card-template');
|
||||||
var regionsJsonInput = document.getElementById('regions-json');
|
var regionsJsonInput = document.getElementById('regions-json');
|
||||||
var templateForm = document.getElementById('template-form');
|
var templateForm = document.getElementById('template-form');
|
||||||
@@ -63,6 +67,7 @@
|
|||||||
var draft = null;
|
var draft = null;
|
||||||
var selectedIndex = -1;
|
var selectedIndex = -1;
|
||||||
var activeAnimationCard = null;
|
var activeAnimationCard = null;
|
||||||
|
var activeDefaultContentCard = null;
|
||||||
var overlayRenderFrame = 0;
|
var overlayRenderFrame = 0;
|
||||||
var stageResizeObserver = null;
|
var stageResizeObserver = null;
|
||||||
var regionNameValidationToastShown = false;
|
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)); }
|
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() {
|
function getOverlayRect() {
|
||||||
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
|
return utils.getOverlayRect ? utils.getOverlayRect(overlay) : overlay.getBoundingClientRect();
|
||||||
}
|
}
|
||||||
@@ -1154,6 +1218,14 @@
|
|||||||
if (regionLabelInput) {
|
if (regionLabelInput) {
|
||||||
regionLabelInput.value = region.label || region.region_key || '';
|
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[]"]');
|
var regionLockRatioInput = card.querySelector('[name="region_lock_ratio[]"]');
|
||||||
if (regionLockRatioInput) {
|
if (regionLockRatioInput) {
|
||||||
regionLockRatioInput.value = normalizeLockRatio(region.lock_ratio || (String(region.region_type || '').trim() === 'qr-code' ? '1:1' : ''));
|
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 animationPresetInputs = card.querySelectorAll('[data-animation-preset]');
|
||||||
var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
|
var animationResetButtons = card.querySelectorAll('[data-animation-reset-button]');
|
||||||
var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
|
var animationAdvancedButton = card.querySelector('[data-animation-advanced-button]');
|
||||||
|
var defaultContentButton = card.querySelector('[data-region-default-content-button]');
|
||||||
nameInput.addEventListener('input', function () {
|
nameInput.addEventListener('input', function () {
|
||||||
regionNameValidationToastShown = false;
|
regionNameValidationToastShown = false;
|
||||||
syncRegionIdentity(card, nameInput.value);
|
syncRegionIdentity(card, nameInput.value);
|
||||||
@@ -1219,6 +1292,9 @@
|
|||||||
renderRegionSidebar();
|
renderRegionSidebar();
|
||||||
renderOverlay();
|
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) {
|
Array.prototype.forEach.call(animationPresetInputs, function (input) {
|
||||||
input.addEventListener('change', function () {
|
input.addEventListener('change', function () {
|
||||||
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
|
setAnimationPreset(card, input.getAttribute('data-animation-preset'), input.value);
|
||||||
|
|||||||
@@ -54,6 +54,10 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
region_type: region.region_type,
|
region_type: region.region_type,
|
||||||
label: region.label,
|
label: region.label,
|
||||||
lock_ratio: region.lock_ratio,
|
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,
|
animation_json: animation,
|
||||||
x: Number(region.x),
|
x: Number(region.x),
|
||||||
y: Number(region.y),
|
y: Number(region.y),
|
||||||
@@ -64,8 +68,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async function fetchSlideFormData() {
|
async function fetchSlideFormData(includeTemplates) {
|
||||||
const data = await common.fetchTemplatesData(pool);
|
const data = includeTemplates === false ? {} : await common.fetchTemplatesData(pool);
|
||||||
const rssData = await common.fetchRssFeedsData(pool);
|
const rssData = await common.fetchRssFeedsData(pool);
|
||||||
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
const apiData = typeof common.fetchApiSourcesData === 'function' ? await common.fetchApiSourcesData(pool) : { apiSources: [] };
|
||||||
const timetableData = typeof common.fetchTimetablesData === 'function' ? await common.fetchTimetablesData(pool) : { timetableGroups: [] };
|
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') {
|
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.');
|
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) {
|
app.get('/templates/new', requirePermission('templates.create'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const data = await common.fetchCanvasSizesData(pool);
|
const [data, slideEditorData] = await Promise.all([common.fetchCanvasSizesData(pool), fetchSlideFormData(false)]);
|
||||||
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
|
const regionEditorData = pickTemplateRegionEditorData(slideEditorData);
|
||||||
const mediaAssets = (await fetchMediaAssets(pool)).slice(0, 24);
|
const imageUploadMaxBytes = Number(regionEditorData.uploadLimits.imageMaxBytes) || IMAGE_UPLOAD_MAX_BYTES;
|
||||||
const settings = await fetchAppSettings(pool);
|
res.send(pages.renderTemplateAddPage(null, req.query.message ? String(req.query.message) : '', data.canvasSizes, req.currentUser, regionEditorData.mediaAssets, imageUploadMaxBytes, regionEditorData));
|
||||||
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));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -724,8 +740,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||||
const region = payload.regions[i];
|
const region = payload.regions[i];
|
||||||
await pool.query(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'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 || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
[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));
|
await publishMediaAssets(pool, collectUploadReferencesFromPayload(payload));
|
||||||
@@ -755,11 +771,10 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
template.region_usage = await fetchTemplateRegionUsage(template);
|
template.region_usage = await fetchTemplateRegionUsage(template);
|
||||||
template.slide_count = (await fetchSlidesByTemplateId(template.id)).length;
|
template.slide_count = (await fetchSlidesByTemplateId(template.id)).length;
|
||||||
template.inUse = template.slide_count > 0;
|
template.inUse = template.slide_count > 0;
|
||||||
const sizeData = await common.fetchCanvasSizesData(pool, template.canvas_size_id);
|
const [sizeData, slideEditorData] = await Promise.all([common.fetchCanvasSizesData(pool, template.canvas_size_id), fetchSlideFormData(false)]);
|
||||||
await syncMediaAssetsFromDirectory(pool, deps.uploadDir);
|
const regionEditorData = pickTemplateRegionEditorData(slideEditorData);
|
||||||
const mediaAssets = await fetchMediaAssets(pool);
|
const imageUploadMaxBytes = Number(regionEditorData.uploadLimits.imageMaxBytes) || IMAGE_UPLOAD_MAX_BYTES;
|
||||||
const settings = await fetchAppSettings(pool);
|
res.send(pages.renderTemplateEditPage(template, sizeData, req.query.message ? String(req.query.message) : '', req.currentUser, regionEditorData.mediaAssets, imageUploadMaxBytes, regionEditorData));
|
||||||
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));
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
@@ -980,8 +995,8 @@ module.exports = function registerContentRoutes(app, deps) {
|
|||||||
for (let i = 0; i < payload.regions.length; i += 1) {
|
for (let i = 0; i < payload.regions.length; i += 1) {
|
||||||
const region = payload.regions[i];
|
const region = payload.regions[i];
|
||||||
await pool.query(
|
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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)',
|
'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 || {}), region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
|
[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)))));
|
await publishMediaAssets(pool, Array.from(new Set(Array.from(existingUploadRefs).concat(Array.from(nextUploadRefs)))));
|
||||||
|
|||||||
@@ -9,12 +9,13 @@ const { fetchAppSettings } = require('#src/data/app-settings');
|
|||||||
|
|
||||||
async function getDataSourceUsageMaps(pool, common) {
|
async function getDataSourceUsageMaps(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
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();
|
const rssFeedIds = new Set();
|
||||||
|
|
||||||
for (let index = 0; index < slides.length; index += 1) {
|
function collectUsage(json) {
|
||||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
|
||||||
if (!content || typeof content !== 'object') {
|
if (!content || typeof content !== 'object') {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(function walk(value) {
|
(function walk(value) {
|
||||||
@@ -40,6 +41,9 @@ async function getDataSourceUsageMaps(pool, common) {
|
|||||||
})(content);
|
})(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slides.forEach(function (slide) { collectUsage(slide.content_json); });
|
||||||
|
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
|
||||||
|
|
||||||
return rssFeedIds;
|
return rssFeedIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -119,32 +123,8 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
|||||||
return res.status(404).send('RSS feed not found');
|
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 usageIds = await getDataSourceUsageMaps(pool, common);
|
||||||
const inUse = (slides || []).some(function (row) {
|
const inUse = usageIds.has(Number(rssFeed.id));
|
||||||
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 pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
const pulledItems = typeof common.fetchRssFeedItemsByFeedId === 'function'
|
||||||
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
? await common.fetchRssFeedItemsByFeedId(pool, rssFeed.id)
|
||||||
@@ -317,7 +297,7 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (await isRssFeedInUse(pool, common, rssFeed.id)) {
|
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();
|
const connection = await pool.getConnection();
|
||||||
@@ -337,4 +317,6 @@ module.exports = function registerRssFeedRoutes(app, deps) {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports.getDataSourceUsageMaps = getDataSourceUsageMaps;
|
||||||
@@ -8,12 +8,13 @@ const { buildDuplicateTimetableGroupName, buildDuplicateTimetableGroup, buildDup
|
|||||||
|
|
||||||
async function getDataSourceUsageMaps(pool, common) {
|
async function getDataSourceUsageMaps(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
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();
|
const timetableGroupIds = new Set();
|
||||||
|
|
||||||
for (let index = 0; index < slides.length; index += 1) {
|
function collectUsage(json) {
|
||||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slides[index].content_json) : null;
|
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
|
||||||
if (!content || typeof content !== 'object') {
|
if (!content || typeof content !== 'object') {
|
||||||
continue;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
(function walk(value) {
|
(function walk(value) {
|
||||||
@@ -39,6 +40,9 @@ async function getDataSourceUsageMaps(pool, common) {
|
|||||||
})(content);
|
})(content);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
slides.forEach(function (slide) { collectUsage(slide.content_json); });
|
||||||
|
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
|
||||||
|
|
||||||
return timetableGroupIds;
|
return timetableGroupIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -324,7 +328,7 @@ module.exports = function registerTimetableRoutes(app, deps) {
|
|||||||
|
|
||||||
const usageIds = await getDataSourceUsageMaps(pool, common);
|
const usageIds = await getDataSourceUsageMaps(pool, common);
|
||||||
if (usageIds.has(Number(timetableGroup.id))) {
|
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();
|
const connection = await pool.getConnection();
|
||||||
@@ -343,4 +347,6 @@ module.exports = function registerTimetableRoutes(app, deps) {
|
|||||||
next(error);
|
next(error);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports.getDataSourceUsageMaps = getDataSourceUsageMaps;
|
||||||
@@ -10,9 +10,10 @@ const { buildAuditChanges } = require('#src/data/audit-log');
|
|||||||
|
|
||||||
async function getWeatherLocationUsageIds(pool, common) {
|
async function getWeatherLocationUsageIds(pool, common) {
|
||||||
const [slides] = await pool.query('SELECT content_json FROM c_slides WHERE content_json IS NOT NULL');
|
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();
|
const weatherLocationIds = new Set();
|
||||||
slides.forEach(function (slide) {
|
function collectUsage(json) {
|
||||||
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(slide.content_json) : null;
|
const content = typeof common.parseJsonSafe === 'function' ? common.parseJsonSafe(json) : null;
|
||||||
if (!content || typeof content !== 'object') return;
|
if (!content || typeof content !== 'object') return;
|
||||||
(function walk(value) {
|
(function walk(value) {
|
||||||
if (!value || typeof value !== 'object') return;
|
if (!value || typeof value !== 'object') return;
|
||||||
@@ -26,7 +27,9 @@ async function getWeatherLocationUsageIds(pool, common) {
|
|||||||
}
|
}
|
||||||
Object.keys(value).forEach(function (key) { walk(value[key]); });
|
Object.keys(value).forEach(function (key) { walk(value[key]); });
|
||||||
}(content));
|
}(content));
|
||||||
});
|
}
|
||||||
|
slides.forEach(function (slide) { collectUsage(slide.content_json); });
|
||||||
|
templateRegions.forEach(function (region) { collectUsage(region.default_content_json); });
|
||||||
return weatherLocationIds;
|
return weatherLocationIds;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -189,7 +192,7 @@ module.exports = function registerWeatherRoutes(app, deps) {
|
|||||||
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
|
const location = await common.fetchWeatherLocationById(pool, Number(req.params.id));
|
||||||
if (!location) return res.status(404).send('Weather location not found');
|
if (!location) return res.status(404).send('Weather location not found');
|
||||||
if (await isWeatherLocationInUse(pool, common, location.id)) {
|
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]);
|
await pool.query('DELETE FROM i_weather_locations WHERE id = ?', [location.id]);
|
||||||
dataSourceTasks.removeRecurringRefresh('weather-location', 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.'));
|
res.redirect('/data-sources/weather?message=' + encodeURIComponent('Weather location deleted.'));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports.getWeatherLocationUsageIds = getWeatherLocationUsageIds;
|
||||||
@@ -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',
|
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
|
||||||
[family]
|
[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);
|
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',
|
'SELECT COUNT(*) AS match_count FROM c_slides WHERE LOCATE(LOWER(?), LOWER(COALESCE(content_json, ""))) > 0',
|
||||||
[normalizedFamily]
|
[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) {
|
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() : '';
|
const family = currentFont ? String(currentFont.family || currentFont.name || '').trim() : '';
|
||||||
if (family && await isFontUsed(pool, family)) {
|
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);
|
const removed = await fontLibrary.deleteFontFile(uploadDir, fontId);
|
||||||
|
|||||||
@@ -5,6 +5,22 @@ const { fetchPlayerRegistrations } = require('#src/data/player-registry');
|
|||||||
const { buildPagination } = require('#src/web/lib/pagination');
|
const { buildPagination } = require('#src/web/lib/pagination');
|
||||||
const { createSearchMatcher, getSearchQuery, getSortDirectionQuery, getSortQuery, parsePageNumber, sortRows } = require('#src/web/lib/list-query');
|
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) {
|
module.exports = function registerWeatherIconRoutes(app, deps) {
|
||||||
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
const recordRequestAuditEvent = deps.recordRequestAuditEvent;
|
||||||
const upload = multer({ storage: multer.memoryStorage(), limits: { fileSize: 5 * 1024 * 1024, files: library.CONDITIONS.length * 2, fields: 2 } });
|
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) {
|
async function page(req, res, message, status) {
|
||||||
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
|
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 search = getSearchQuery(req);
|
||||||
const sort = getSortQuery(req);
|
const sort = getSortQuery(req);
|
||||||
const direction = getSortDirectionQuery(req);
|
const direction = getSortDirectionQuery(req);
|
||||||
const sets = sortRows(library.readWeatherIconSets(deps.mediaDir).map(function (set) {
|
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 || {};
|
const content = typeof slide.content_json === 'string' ? JSON.parse(slide.content_json || '{}') : slide.content_json || {};
|
||||||
return Object.values(content).some(function (region) {
|
return Object.values(content).some(function (region) {
|
||||||
return region && region.weather_icon_set_id === set.id;
|
return region && region.weather_icon_set_id === set.id;
|
||||||
});
|
});
|
||||||
}).length;
|
}).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({}, 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 });
|
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) {
|
app.post('/settings/weather-icons/:id/delete', deps.requirePermission('weather-icons.delete'), async function (req, res, next) {
|
||||||
try {
|
try {
|
||||||
const [slides] = await deps.pool.query('SELECT id, content_json FROM c_slides');
|
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 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);
|
|
||||||
const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id);
|
const removed = library.deleteWeatherIconSet(deps.mediaDir, req.params.id);
|
||||||
if (typeof recordRequestAuditEvent === 'function') {
|
if (typeof recordRequestAuditEvent === 'function') {
|
||||||
await recordRequestAuditEvent(deps.pool, req, {
|
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.'));
|
res.redirect('/settings/weather-icons?message=' + encodeURIComponent('Weather icon set deleted.'));
|
||||||
} catch (error) { next(error); }
|
} catch (error) { next(error); }
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
module.exports.isWeatherIconSetUsed = isWeatherIconSetUsed;
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
const { renderView } = require('#src/web/view');
|
const { renderView } = require('#src/web/view');
|
||||||
const { buildTemplateFormViewModel } = require('#src/web/routes/signage/templates/form-view-model');
|
const { buildTemplateFormViewModel } = require('#src/web/routes/signage/templates/form-view-model');
|
||||||
|
|
||||||
module.exports = function renderTemplateAddPage(template, message, canvasSizes, currentUser, 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));
|
return renderView('templates/form', buildTemplateFormViewModel(template, message, canvasSizes, currentUser, false, mediaAssets, imageUploadMaxBytes, regionEditorData));
|
||||||
};
|
};
|
||||||
@@ -3,6 +3,6 @@
|
|||||||
const { renderView } = require('#src/web/view');
|
const { renderView } = require('#src/web/view');
|
||||||
const { buildTemplateFormViewModel } = require('#src/web/routes/signage/templates/form-view-model');
|
const { buildTemplateFormViewModel } = require('#src/web/routes/signage/templates/form-view-model');
|
||||||
|
|
||||||
module.exports = function renderTemplateEditPage(template, data, message, currentUser, 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));
|
return renderView('templates/form', buildTemplateFormViewModel(template, message, data && data.canvasSizes ? data.canvasSizes : [], currentUser, true, mediaAssets, imageUploadMaxBytes, regionEditorData));
|
||||||
};
|
};
|
||||||
@@ -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 current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
|
||||||
const assetVersion = Date.now().toString(36);
|
const assetVersion = Date.now().toString(36);
|
||||||
|
|
||||||
@@ -65,6 +65,7 @@ function buildTemplateFormViewModel(template, message, canvasSizes, currentUser,
|
|||||||
deleteConfirmMessage: isEdit ? 'Delete this template?' : '',
|
deleteConfirmMessage: isEdit ? 'Delete this template?' : '',
|
||||||
canvasSizes: canvasSizes || [],
|
canvasSizes: canvasSizes || [],
|
||||||
mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [],
|
mediaAssets: Array.isArray(mediaAssets) ? mediaAssets : [],
|
||||||
|
regionEditorData: regionEditorData || {},
|
||||||
imageUploadMaxBytes: Number(imageUploadMaxBytes) || 100 * 1024 * 1024,
|
imageUploadMaxBytes: Number(imageUploadMaxBytes) || 100 * 1024 * 1024,
|
||||||
animationPresets: animationPresets,
|
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])
|
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])
|
||||||
|
|||||||
@@ -51,7 +51,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-media-picker-cancel>Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" data-media-picker-confirm>Confirm selection</button>
|
<button type="button" class="btn btn-primary" data-media-picker-confirm>Confirm selection</button>
|
||||||
|
<button type="button" class="btn btn-warning" data-media-picker-cancel>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
{{/modal-shell}}
|
{{/modal-shell}}
|
||||||
@@ -115,8 +115,8 @@
|
|||||||
<div class="modal-footer justify-content-end">
|
<div class="modal-footer justify-content-end">
|
||||||
<div class="d-flex flex-wrap align-items-center justify-content-end gap-2 w-100">
|
<div class="d-flex flex-wrap align-items-center justify-content-end gap-2 w-100">
|
||||||
<div class="text-body-secondary small text-end" id="playlist-slide-picker-selected-count-wrap"><span id="playlist-slide-picker-selected-count">0</span> selected</div>
|
<div class="text-body-secondary small text-end" id="playlist-slide-picker-selected-count-wrap"><span id="playlist-slide-picker-selected-count">0</span> selected</div>
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
<button type="button" class="btn btn-success" id="playlist-confirm-add-slides" disabled>Add selected slides</button>
|
||||||
<button type="button" class="btn btn-primary" id="playlist-confirm-add-slides" disabled>Add selected slides</button>
|
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/modal-shell}}
|
{{/modal-shell}}
|
||||||
@@ -7,7 +7,8 @@
|
|||||||
<div class="d-flex align-items-center gap-4 ms-auto flex-shrink-0">
|
<div class="d-flex align-items-center gap-4 ms-auto flex-shrink-0">
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" data-remove-schedule-rule data-confirm-message="Remove this rule?">Remove</button>
|
<button type="button" class="btn btn-sm btn-outline-danger" data-remove-schedule-rule data-confirm-message="Remove this rule?">Remove</button>
|
||||||
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-expanded="{{#if isCollapsed}}false{{else}}true{{/if}}" aria-label="Collapse rule {{ruleIndex}}" title="Collapse rule {{ruleIndex}}">
|
<button type="button" class="btn btn-tool p-0 text-body-secondary" data-lte-toggle="card-collapse" aria-expanded="{{#if isCollapsed}}false{{else}}true{{/if}}" aria-label="Collapse rule {{ruleIndex}}" title="Collapse rule {{ruleIndex}}">
|
||||||
<i class="bi {{#if isCollapsed}}bi-plus-lg{{else}}bi-dash-lg{{/if}}" aria-hidden="true" data-schedule-rule-collapse-icon></i>
|
<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"></i>
|
||||||
|
<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"></i>
|
||||||
<span class="visually-hidden">Collapse rule {{ruleIndex}}</span>
|
<span class="visually-hidden">Collapse rule {{ruleIndex}}</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -26,11 +26,9 @@
|
|||||||
{{> signage/playlists/schedule-rule-card ruleIndex=1 ruleSummary="" startDateTimeValue="" endDateTimeValue="" startTimeValue="" endTimeValue="" dayOptions=templateDayOptions isCollapsed=true}}
|
{{> signage/playlists/schedule-rule-card ruleIndex=1 ruleSummary="" startDateTimeValue="" endDateTimeValue="" startTimeValue="" endTimeValue="" dayOptions=templateDayOptions isCollapsed=true}}
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-footer d-flex justify-content-end">
|
<div class="card-footer d-flex justify-content-end gap-2">
|
||||||
<div class="btn-group" role="group" aria-label="Slide schedule actions">
|
<button type="submit" class="btn btn-primary">Save schedule</button>
|
||||||
<button type="submit" class="btn btn-primary">Save schedule</button>
|
<button type="button" class="btn btn-warning" data-schedule-cancel data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</button>
|
||||||
<button type="button" class="btn btn-warning" data-schedule-cancel data-confirm-unsaved="You have unsaved changes. Leave this page?">Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,12 @@
|
|||||||
<input id="slide-title" name="title" class="form-control" value="{{slide.title}}" maxlength="128" data-limit-text-length required />
|
<input id="slide-title" name="title" class="form-control" value="{{slide.title}}" maxlength="128" data-limit-text-length required />
|
||||||
</div>
|
</div>
|
||||||
<div class="col-6">
|
<div class="col-6">
|
||||||
<label for="template-select" class="form-label">Template</label>
|
<span id="template-select-label" class="form-label d-block">Template</span>
|
||||||
<select name="template_id" id="template-select" class="form-select">
|
<button type="button" id="template-picker-open" class="btn btn-outline-secondary w-100 d-flex align-items-center justify-content-between gap-2 text-start" aria-haspopup="dialog" aria-controls="template-picker-modal" aria-labelledby="template-select-label template-picker-selected-name">
|
||||||
|
<span class="template-picker-current"><strong id="template-picker-selected-name">No template selected</strong></span>
|
||||||
|
<i class="bi bi-grid-3x3-gap flex-shrink-0" aria-hidden="true"></i>
|
||||||
|
</button>
|
||||||
|
<select name="template_id" id="template-select" class="visually-hidden" tabindex="-1" aria-hidden="true">
|
||||||
<option value="">-- none --</option>
|
<option value="">-- none --</option>
|
||||||
{{#each templates}}
|
{{#each templates}}
|
||||||
<option value="{{id}}" {{#if (eq ../slide.template_id id)}}selected{{/if}}>{{name}}</option>
|
<option value="{{id}}" {{#if (eq ../slide.template_id id)}}selected{{/if}}>{{name}}</option>
|
||||||
@@ -79,6 +83,28 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{{#> modal-shell modalId="template-picker-modal" modalLabelId="template-picker-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable template-picker-dialog"}}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title fs-5" id="template-picker-modal-label">Choose a template</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<div class="template-picker-toolbar mb-3">
|
||||||
|
<label class="visually-hidden" for="template-picker-search">Search templates</label>
|
||||||
|
<input id="template-picker-search" type="search" class="form-control" placeholder="Search templates" autocomplete="off" />
|
||||||
|
<label class="visually-hidden" for="template-picker-canvas-size">Filter by canvas size</label>
|
||||||
|
<select id="template-picker-canvas-size" class="form-select" aria-label="Filter by canvas size">
|
||||||
|
<option value="">All canvas sizes</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="template-picker-grid" class="template-picker-grid" role="listbox" aria-label="Templates"></div>
|
||||||
|
<p id="template-picker-empty" class="text-body-secondary text-center py-4" hidden>No templates match these filters.</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
{{/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"}}
|
{{#> modal-shell modalId="slide-layout-modal" modalLabelId="slide-layout-modal-label" modalDialogClass="modal-xl modal-dialog-centered modal-dialog-scrollable slide-layout-dialog"}}
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h2 class="modal-title fs-5" id="slide-layout-modal-label">Adjust layout <span class="text-body-secondary fs-6">This slide only</span></h2>
|
<h2 class="modal-title fs-5" id="slide-layout-modal-label">Adjust layout <span class="text-body-secondary fs-6">This slide only</span></h2>
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
<div class="modal-footer justify-content-between">
|
<div class="modal-footer justify-content-between">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button>
|
<button type="button" class="btn btn-outline-secondary" data-animation-modal-reset>Reset to defaults</button>
|
||||||
<div class="d-flex gap-2">
|
<div class="d-flex gap-2">
|
||||||
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" data-animation-modal-apply>Apply changes</button>
|
<button type="button" class="btn btn-primary" data-animation-modal-apply>Apply changes</button>
|
||||||
|
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{{/modal-shell}}
|
{{/modal-shell}}
|
||||||
@@ -169,7 +169,12 @@
|
|||||||
<input type="hidden" name="region_type[]" value="text" />
|
<input type="hidden" name="region_type[]" value="text" />
|
||||||
<input type="hidden" name="region_key[]" value="" />
|
<input type="hidden" name="region_key[]" value="" />
|
||||||
<input type="hidden" name="region_label[]" value="" />
|
<input type="hidden" name="region_label[]" value="" />
|
||||||
|
<input type="hidden" data-region-default-content value="{}" />
|
||||||
{{> signage/templates/region-layout-fields}}
|
{{> signage/templates/region-layout-fields}}
|
||||||
|
<div class="d-flex align-items-center gap-3 border-top pt-3 mt-1">
|
||||||
|
<button type="button" class="btn btn-outline-secondary flex-grow-1" data-region-default-content-button title="Edit template default content"><i class="bi bi-pencil-square me-1" aria-hidden="true"></i>Edit default</button>
|
||||||
|
<label class="form-check form-switch d-inline-flex align-items-center gap-2 mb-0 ps-0 text-nowrap flex-shrink-0" title="Hidden in the slide editor. Edit this content on the template."><input class="form-check-input m-0 flex-shrink-0" type="checkbox" data-region-read-only /><span class="form-check-label">Template-only</span></label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -179,5 +184,33 @@
|
|||||||
|
|
||||||
{{> media-picker-modal mediaAssets=mediaAssets}}
|
{{> 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"}}
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 class="modal-title fs-5" id="region-default-content-modal-label">Default content</h2>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body slide-animation-body template-default-content-body">
|
||||||
|
<aside class="slide-animation-properties template-default-content-properties">
|
||||||
|
<div id="region-default-content-editor-host"></div>
|
||||||
|
</aside>
|
||||||
|
<section class="slide-animation-preview-panel template-default-content-workspace">
|
||||||
|
<div id="region-default-content-preview-stage" class="template-default-content-preview-stage">
|
||||||
|
<div id="region-default-content-preview-frame" class="template-default-content-preview-frame">
|
||||||
|
<img id="region-default-content-preview-background" class="template-default-content-preview-background" alt="" hidden />
|
||||||
|
<div id="region-default-content-preview-canvas" class="template-default-content-preview-canvas"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="region-default-content-preview-empty" class="text-body-secondary text-center py-2" hidden>No content yet. The preview shows the region's template position.</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-primary" id="region-default-content-save"><i class="bi bi-check-lg me-1" aria-hidden="true"></i>Apply content</button>
|
||||||
|
<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel</button>
|
||||||
|
</div>
|
||||||
|
{{/modal-shell}}
|
||||||
|
|
||||||
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
|
<textarea id="template-editor-data" hidden>{{json template.regions}}</textarea>
|
||||||
|
<textarea id="template-region-editor-data" hidden>{{json regionEditorData}}</textarea>
|
||||||
|
<script src="/assets/vendor/tinymce/tinymce.min.js"></script>
|
||||||
|
<script type="module" src="/assets/js/templates/template-default-content-editor.js?v={{appVersion}}"></script>
|
||||||
<script type="module" src="/assets/js/media-picker.js?v={{appVersion}}"></script>
|
<script type="module" src="/assets/js/media-picker.js?v={{appVersion}}"></script>
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
const test = require('node:test');
|
const test = require('node:test');
|
||||||
const assert = require('node:assert/strict');
|
const assert = require('node:assert/strict');
|
||||||
const path = require('node:path');
|
const path = require('node:path');
|
||||||
|
const fs = require('node:fs');
|
||||||
const Handlebars = require('handlebars');
|
const Handlebars = require('handlebars');
|
||||||
|
|
||||||
const { registerHandlebars } = require('../src/web/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="new">Save and New<\/button>/);
|
||||||
assert.match(html, /name="save_action" value="close">Save and Close<\/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, /<button type="button" class="btn btn-success" id="playlist-confirm-add-slides" disabled>Add selected slides<\/button>\s*<button type="button" class="btn btn-warning" data-bs-dismiss="modal">Cancel<\/button>/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playlist schedule actions are separate spaced buttons', () => {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'views', 'signage', 'playlists', 'slide-config.hbs'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /<div class="card-footer d-flex justify-content-end gap-2">\s*<button type="submit" class="btn btn-primary">Save schedule<\/button>\s*<button type="button" class="btn btn-warning" data-schedule-cancel/);
|
||||||
|
assert.doesNotMatch(source, /\bbtn-group\b/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('playlist schedule footer excludes the clearfix spacer from its flex actions', () => {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'css', 'theme-custom.css'), 'utf8');
|
||||||
|
|
||||||
|
assert.match(source, /^\.slide-schedule-card > form > \.card-footer::after\s*\{\s*content: none;\s*\}/m);
|
||||||
});
|
});
|
||||||
@@ -8,7 +8,7 @@ function payload(requests, auth) {
|
|||||||
return buildApiGroupPayload(Object.assign({ name: 'Tiltify', auth_method: 'none', requests_json: JSON.stringify(requests) }, auth));
|
return buildApiGroupPayload(Object.assign({ name: 'Tiltify', auth_method: 'none', requests_json: JSON.stringify(requests) }, auth));
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPool(existing, slides, groupEnabled = 1) {
|
function createPool(existing, slides, groupEnabled = 1, templateDefaults = []) {
|
||||||
const calls = [];
|
const calls = [];
|
||||||
const connection = {
|
const connection = {
|
||||||
async beginTransaction() { calls.push(['begin']); },
|
async beginTransaction() { calls.push(['begin']); },
|
||||||
@@ -20,6 +20,7 @@ function createPool(existing, slides, groupEnabled = 1) {
|
|||||||
if (sql.includes('SELECT id, enabled FROM i_api_source_groups WHERE id')) return [[{ id: 5, enabled: groupEnabled }]];
|
if (sql.includes('SELECT id, enabled FROM i_api_source_groups WHERE id')) return [[{ id: 5, enabled: groupEnabled }]];
|
||||||
if (sql.includes('SELECT id FROM i_api_source_groups WHERE name')) return [[]];
|
if (sql.includes('SELECT id FROM i_api_source_groups WHERE name')) return [[]];
|
||||||
if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))];
|
if (sql.includes('SELECT id FROM i_api_sources')) return [existing.map(id => ({ id }))];
|
||||||
|
if (sql.includes('SELECT default_content_json FROM c_template_regions')) return [templateDefaults.map(value => ({ default_content_json: value }))];
|
||||||
if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))];
|
if (sql.includes('SELECT content_json')) return [(slides || []).map(value => ({ content_json: value }))];
|
||||||
if (sql.startsWith('INSERT INTO i_api_sources')) return [{ insertId: 99 }];
|
if (sql.startsWith('INSERT INTO i_api_sources')) return [{ insertId: 99 }];
|
||||||
if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }];
|
if (sql.startsWith('INSERT INTO i_api_source_groups')) return [{ insertId: 10 }];
|
||||||
@@ -64,6 +65,9 @@ test('request ownership, slide usage, and delete permission are enforced before
|
|||||||
const used = createPool([5, 6], [JSON.stringify({ region: { type: 'api', source_id: 6 } })]);
|
const used = createPool([5, 6], [JSON.stringify({ region: { type: 'api', source_id: 6 } })]);
|
||||||
await assert.rejects(saveApiGroup(used, 5, payload([request]), 1, true), /still used/);
|
await assert.rejects(saveApiGroup(used, 5, payload([request]), 1, true), /still used/);
|
||||||
assert.ok(!used.calls.some(([sql]) => sql.startsWith('UPDATE')));
|
assert.ok(!used.calls.some(([sql]) => sql.startsWith('UPDATE')));
|
||||||
|
const usedByTemplate = createPool([5, 6], [], 1, [JSON.stringify({ source_id: 6 })]);
|
||||||
|
await assert.rejects(saveApiGroup(usedByTemplate, 5, payload([request]), 1, true), /still used/);
|
||||||
|
assert.ok(!usedByTemplate.calls.some(([sql]) => sql.startsWith('UPDATE')));
|
||||||
await assert.rejects(saveApiGroup(createPool([5, 6]), 5, payload([request]), 1, false), /permission/);
|
await assert.rejects(saveApiGroup(createPool([5, 6]), 5, payload([request]), 1, false), /permission/);
|
||||||
const unused = createPool([5, 6]);
|
const unused = createPool([5, 6]);
|
||||||
const result = await saveApiGroup(unused, 5, payload([request]), 1, true);
|
const result = await saveApiGroup(unused, 5, payload([request]), 1, true);
|
||||||
|
|||||||
@@ -185,6 +185,43 @@ test('fonts route sorts by format and status', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('template default font references mark fonts in use and block deletion', async () => {
|
||||||
|
const originals = {
|
||||||
|
loadFontLibrary: fontLibrary.loadFontLibrary,
|
||||||
|
deleteFontFile: fontLibrary.deleteFontFile
|
||||||
|
};
|
||||||
|
const { app, handlers } = createHandlers();
|
||||||
|
const font = { id: 'alpha.woff2', family: 'Alpha Sans', name: 'Alpha Sans', fileName: 'alpha.woff2', format: 'woff2', enabled: true };
|
||||||
|
let deleteCount = 0;
|
||||||
|
fontLibrary.loadFontLibrary = function () { return { fonts: [font], stylesheetHref: '' }; };
|
||||||
|
fontLibrary.deleteFontFile = async function () { deleteCount += 1; return font; };
|
||||||
|
registerFontRoutes(app, {
|
||||||
|
pool: {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM c_template_regions')) return [[{ match_count: 1 }]];
|
||||||
|
return [[{ match_count: 0 }]];
|
||||||
|
}
|
||||||
|
},
|
||||||
|
pages: { renderFontsPage(data) { return JSON.stringify(data); } },
|
||||||
|
upload: { single() { return function (_req, _res, next) { next(); }; } },
|
||||||
|
uploadDir: 'unused',
|
||||||
|
backgroundTaskQueue: null
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = { body: '', send(value) { this.body = value; return this; }, redirect(value) { this.location = value; return this; } };
|
||||||
|
const req = { query: {}, currentUser: { permissions: ['fonts.read', 'fonts.delete'] }, params: { id: font.id } };
|
||||||
|
await handlers['GET /settings/fonts'][1](req, response, error => { throw error; });
|
||||||
|
assert.equal(JSON.parse(response.body).fonts[0].inUse, true);
|
||||||
|
await handlers['POST /settings/fonts/:id/delete'].at(-1)(req, response, error => { throw error; });
|
||||||
|
assert.match(decodeURIComponent(response.location), /font is still used by slides or template defaults/);
|
||||||
|
assert.equal(deleteCount, 0);
|
||||||
|
} finally {
|
||||||
|
fontLibrary.loadFontLibrary = originals.loadFontLibrary;
|
||||||
|
fontLibrary.deleteFontFile = originals.deleteFontFile;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
test('fonts page renders the upload card above the table card', () => {
|
test('fonts page renders the upload card above the table card', () => {
|
||||||
const html = renderFontsPage({
|
const html = renderFontsPage({
|
||||||
fonts: [
|
fonts: [
|
||||||
|
|||||||
@@ -166,7 +166,7 @@ test('media library backfills existing image and video uploads', async () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
test('media library counts slide and template references', async () => {
|
test('media library counts slide, template background, and region default references', async () => {
|
||||||
const queries = [];
|
const queries = [];
|
||||||
const pool = {
|
const pool = {
|
||||||
async query(sql, params) {
|
async query(sql, params) {
|
||||||
@@ -174,16 +174,19 @@ test('media library counts slide and template references', async () => {
|
|||||||
if (sql.includes('FROM c_slides')) {
|
if (sql.includes('FROM c_slides')) {
|
||||||
return [[{ ref_count: 2 }]];
|
return [[{ ref_count: 2 }]];
|
||||||
}
|
}
|
||||||
return [[{ ref_count: 1 }]];
|
if (sql.includes('FROM c_templates')) return [[{ ref_count: 1 }]];
|
||||||
|
return [[{ ref_count: 3 }]];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 3);
|
assert.equal(await countMediaAssetReferences(pool, '/media/uploads/shared.png'), 6);
|
||||||
assert.equal(queries.length, 2);
|
assert.equal(queries.length, 3);
|
||||||
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
|
assert.equal(queries[0].params[0], '/media/uploads/shared.png');
|
||||||
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
|
assert.equal(queries[1].params[0], '/media/uploads/shared.png');
|
||||||
|
assert.equal(queries[2].params[0], '/media/uploads/shared.png');
|
||||||
assert.match(queries[0].sql, /LOCATE/);
|
assert.match(queries[0].sql, /LOCATE/);
|
||||||
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
|
assert.match(queries[0].sql, /CAST\(COALESCE\(content_json, ''\) AS CHAR\)/);
|
||||||
|
assert.match(queries[2].sql, /default_content_json/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('media library upload and delete routes audit successful asset changes', async () => {
|
test('media library upload and delete routes audit successful asset changes', async () => {
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
id: 101,
|
id: 101,
|
||||||
title: 'Intro',
|
title: 'Intro',
|
||||||
template_id: 33,
|
template_id: 33,
|
||||||
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
|
content_json: '{"videoRegion":{"type":"video","duration_seconds":12.3456},"backupVideo":{"type":"video","duration_seconds":20.1111},"textRegion":{"type":"text","value":"Hello"},"brandTagline":{"type":"text","value":"Old slide value"},"qrRegion":{"type":"qr-code","value":"https://example.com"}}',
|
||||||
modified_at: '2026-08-03T00:00:01.000Z',
|
modified_at: '2026-08-03T00:00:01.000Z',
|
||||||
position: 1,
|
position: 1,
|
||||||
duration_seconds: 9,
|
duration_seconds: 9,
|
||||||
@@ -106,7 +106,10 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
}
|
}
|
||||||
if (sql.includes('FROM c_template_regions')) {
|
if (sql.includes('FROM c_template_regions')) {
|
||||||
assert.deepEqual(params, [[33, 33]]);
|
assert.deepEqual(params, [[33, 33]]);
|
||||||
return [[{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' }]];
|
return [[
|
||||||
|
{ id: 900, template_id: 33, region_key: 'textRegion', region_type: 'text', label: 'Text Region', font_family: 'Arial', x: 10, y: 20, width: 300, height: 200, z_index: 1, modified_at: '2026-08-03T00:00:03.000Z' },
|
||||||
|
{ id: 901, template_id: 33, region_key: 'brandTagline', region_type: 'text', label: 'Brand tagline', default_content: { type: 'text', value: 'Template-owned value' }, read_only: 1, x: 10, y: 20, width: 300, height: 200, z_index: 2 }
|
||||||
|
]];
|
||||||
}
|
}
|
||||||
throw new Error(`unexpected query: ${sql}`);
|
throw new Error(`unexpected query: ${sql}`);
|
||||||
}
|
}
|
||||||
@@ -136,6 +139,7 @@ test('buildScreenPlaylist assembles slides, templates, and derived values', asyn
|
|||||||
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
|
assert.equal(payload.slides[0].content.videoRegion.disable_audio, true);
|
||||||
assert.equal(payload.slides[0].content.backupVideo.disable_audio, true);
|
assert.equal(payload.slides[0].content.backupVideo.disable_audio, true);
|
||||||
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
|
assert.equal(payload.slides[0].content.videoRegion.cache_bust, '2026-08-03T00:00:01.000Z');
|
||||||
|
assert.equal(payload.slides[0].content.brandTagline.value, 'Template-owned value');
|
||||||
assert.equal(payload.slides[1].disable_audio, true);
|
assert.equal(payload.slides[1].disable_audio, true);
|
||||||
assert.equal(payload.slides[1].content.rtmpRegion.disable_audio, true);
|
assert.equal(payload.slides[1].content.rtmpRegion.disable_audio, true);
|
||||||
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
|
assert.equal(typeof payload.slides[0].content.qrRegion.qr_preview, 'string');
|
||||||
|
|||||||
@@ -105,6 +105,56 @@ test('playlist group colours differ, survive reorder, and clear on ungrouped sli
|
|||||||
assert.equal(firstMember.style['--playlist-group-bg'], undefined);
|
assert.equal(firstMember.style['--playlist-group-bg'], undefined);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('schedule rules use the same native AdminLTE icons as slide regions', () => {
|
||||||
|
const root = path.join(__dirname, '..', 'src', 'web');
|
||||||
|
const sources = [
|
||||||
|
fs.readFileSync(path.join(root, 'views', 'signage', 'playlists', 'schedule-rule-card.hbs'), 'utf8'),
|
||||||
|
fs.readFileSync(path.join(root, 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8'),
|
||||||
|
fs.readFileSync(path.join(root, 'public', 'js', 'slides', 'slide-form-regions.js'), 'utf8')
|
||||||
|
];
|
||||||
|
for (const source of sources) {
|
||||||
|
assert.match(source, /<i class="bi bi-dash-lg" aria-hidden="true" data-lte-icon="collapse"><\/i>/);
|
||||||
|
assert.match(source, /<i class="bi bi-plus-lg" aria-hidden="true" data-lte-icon="expand"><\/i>/);
|
||||||
|
assert.doesNotMatch(source, /data-schedule-rule-collapse-icon|getCollapseRuleIcon/);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
test('schedule rule accessibility and drafts follow completed collapse and expand events', () => {
|
||||||
|
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
|
||||||
|
const syncSource = source.match(/function syncRuleCollapseButtonState\(card\) \{[\s\S]*?\n \}/)[0];
|
||||||
|
const handlerSource = source.match(/function handleRuleCollapseComplete\(event\) \{[\s\S]*?\n \}/)[0];
|
||||||
|
const listeners = {};
|
||||||
|
const attributes = {};
|
||||||
|
const label = { textContent: '' };
|
||||||
|
const button = {
|
||||||
|
setAttribute(name, value) { attributes[name] = value; },
|
||||||
|
querySelector() { return label; }
|
||||||
|
};
|
||||||
|
let collapsed = false;
|
||||||
|
let persisted = 0;
|
||||||
|
const card = {
|
||||||
|
classList: { contains() { return collapsed; } },
|
||||||
|
closest() { return card; }
|
||||||
|
};
|
||||||
|
const sandbox = {
|
||||||
|
getCollapseRuleButton() { return button; },
|
||||||
|
getRuleNumberNode() { return { textContent: '1' }; },
|
||||||
|
persistScheduleDraft() { persisted += 1; },
|
||||||
|
scheduleRuleList: { addEventListener(name, handler) { listeners[name] = handler; } }
|
||||||
|
};
|
||||||
|
const registrations = source.match(/ scheduleRuleList.addEventListener\('collapsed\.lte\.card-widget'[^\n]+\n scheduleRuleList.addEventListener\('expanded\.lte\.card-widget'[^\n]+/)[0];
|
||||||
|
require('node:vm').runInNewContext(syncSource + '\n' + handlerSource + '\n' + registrations, sandbox);
|
||||||
|
|
||||||
|
for (const isCollapsed of [true, false, true]) {
|
||||||
|
collapsed = isCollapsed;
|
||||||
|
listeners[isCollapsed ? 'collapsed.lte.card-widget' : 'expanded.lte.card-widget']({ target: card });
|
||||||
|
assert.equal(attributes['aria-expanded'], String(!isCollapsed));
|
||||||
|
assert.equal(attributes['aria-label'], (isCollapsed ? 'Expand' : 'Collapse') + ' rule 1');
|
||||||
|
assert.equal(label.textContent, attributes['aria-label']);
|
||||||
|
}
|
||||||
|
assert.equal(persisted, 3);
|
||||||
|
});
|
||||||
|
|
||||||
test('removing a playlist group removes all of its member rows', () => {
|
test('removing a playlist group removes all of its member rows', () => {
|
||||||
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
|
const source = fs.readFileSync(path.join(__dirname, '..', 'src', 'web', 'public', 'js', 'playlists', 'playlist-schedule.js'), 'utf8');
|
||||||
const functionSource = source.match(/function removePlaylistGroup\(header\) \{[\s\S]*?\n \}/)[0];
|
const functionSource = source.match(/function removePlaylistGroup\(header\) \{[\s\S]*?\n \}/)[0];
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
|
|||||||
addEventListener(type, handler) { this.handlers[type] = handler; },
|
addEventListener(type, handler) { this.handlers[type] = handler; },
|
||||||
querySelectorAll() { return []; }, replaceChildren() {}, appendChild() {}, focus() {},
|
querySelectorAll() { return []; }, replaceChildren() {}, appendChild() {}, focus() {},
|
||||||
setAttribute() {}, removeAttribute() {},
|
setAttribute() {}, removeAttribute() {},
|
||||||
style: { setProperty() {} }, classList: { add() {}, remove() {} } };
|
style: { setProperty() {}, removeProperty() {} }, classList: { add() {}, remove() {}, toggle() {} } };
|
||||||
}
|
}
|
||||||
const ids = Object.fromEntries([
|
const ids = Object.fromEntries([
|
||||||
'slide-animation-modal', 'adjust-slide-animations', 'slide-animation-region',
|
'slide-animation-modal', 'adjust-slide-animations', 'slide-animation-region',
|
||||||
@@ -344,7 +344,17 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
|
|||||||
const advanced = ids['animation-advanced-modal'];
|
const advanced = ids['animation-advanced-modal'];
|
||||||
const advancedApply = element();
|
const advancedApply = element();
|
||||||
const advancedReset = element();
|
const advancedReset = element();
|
||||||
|
const animationPreviewButtons = ['intro', 'outro', 'loop'].map(mode => Object.assign(element(), { dataset: { slideAnimationPreview: mode }, querySelector: () => element() }));
|
||||||
|
const readonlyPreviewRegion = element();
|
||||||
|
readonlyPreviewRegion.className = 'slide-preview-region';
|
||||||
|
readonlyPreviewRegion.dataset.regionKey = 'fixed_brand';
|
||||||
|
readonlyPreviewRegion.classList = {
|
||||||
|
add(...classNames) { readonlyPreviewRegion.className += ' ' + classNames.join(' '); },
|
||||||
|
remove(...classNames) { readonlyPreviewRegion.className = readonlyPreviewRegion.className.split(/\s+/).filter(name => !classNames.includes(name)).join(' '); }
|
||||||
|
};
|
||||||
advanced.querySelectorAll = () => fields;
|
advanced.querySelectorAll = () => fields;
|
||||||
|
ids['slide-animation-modal'].querySelectorAll = selector => selector === '[data-slide-animation-preview]' ? animationPreviewButtons : [];
|
||||||
|
ids['slide-animation-preview'].querySelectorAll = () => [readonlyPreviewRegion];
|
||||||
advanced.querySelector = selector => selector === '[data-animation-modal-apply]' ? advancedApply
|
advanced.querySelector = selector => selector === '[data-animation-modal-apply]' ? advancedApply
|
||||||
: selector === '[data-animation-modal-reset]' ? advancedReset : element();
|
: selector === '[data-animation-modal-reset]' ? advancedReset : element();
|
||||||
const hidden = { value: '{}' };
|
const hidden = { value: '{}' };
|
||||||
@@ -363,7 +373,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
|
|||||||
};
|
};
|
||||||
const modalInstances = new Map();
|
const modalInstances = new Map();
|
||||||
const scrollBarCalls = [];
|
const scrollBarCalls = [];
|
||||||
const context = { window: { innerWidth: 1200, pulseModal: {
|
const context = { window: { innerWidth: 1200, setTimeout() { return 1; }, clearTimeout() {}, pulseModal: {
|
||||||
show(modal) { this.getOrCreate(modal)._scrollBar.hide(); modal.shown = true; },
|
show(modal) { this.getOrCreate(modal)._scrollBar.hide(); modal.shown = true; },
|
||||||
hide(modal) { this.getOrCreate(modal)._scrollBar.reset(); modal.shown = false; modal.handlers['hidden.bs.modal'](); },
|
hide(modal) { this.getOrCreate(modal)._scrollBar.reset(); modal.shown = false; modal.handlers['hidden.bs.modal'](); },
|
||||||
getOrCreate(modal) {
|
getOrCreate(modal) {
|
||||||
@@ -387,12 +397,19 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
|
|||||||
vm.runInNewContext(source.replace('export function', 'function'), context);
|
vm.runInNewContext(source.replace('export function', 'function'), context);
|
||||||
let applied;
|
let applied;
|
||||||
context.createSlideAnimationEditor({ stage: { parentNode: { insertBefore() {} }, nextSibling: null },
|
context.createSlideAnimationEditor({ stage: { parentNode: { insertBefore() {} }, nextSibling: null },
|
||||||
getTemplate() { return { regions: [{ region_key: 'hero', animation_json: { intro: { preset: 'bounceIn' }, outro: { preset: 'fadeOut' } } }] }; },
|
getTemplate() { return { regions: [
|
||||||
|
{ region_key: 'hero', animation_json: { intro: { preset: 'bounceIn' }, outro: { preset: 'fadeOut' } } },
|
||||||
|
{ region_key: 'fixed_brand', read_only: true, animation_json: { loop: { preset: 'pulse', duration_ms: 200, iterations: 1 } } }
|
||||||
|
] }; },
|
||||||
getAnimationOverrides() { return {}; }, requestPreviewRender() {}, markEdited() {},
|
getAnimationOverrides() { return {}; }, requestPreviewRender() {}, markEdited() {},
|
||||||
setAnimationOverrides(value) { applied = value; }
|
setAnimationOverrides(value) { applied = value; }
|
||||||
});
|
});
|
||||||
ids['slide-animation-region'].value = 'hero';
|
ids['slide-animation-region'].value = 'hero';
|
||||||
ids['adjust-slide-animations'].handlers.click();
|
ids['adjust-slide-animations'].handlers.click();
|
||||||
|
assert.equal(animationPreviewButtons[2].disabled, false);
|
||||||
|
animationPreviewButtons[2].handlers.click();
|
||||||
|
assert.match(readonlyPreviewRegion.className, /animate__pulse/);
|
||||||
|
animationPreviewButtons[2].handlers.click();
|
||||||
const resetRegion = ids['slide-animation-reset-region'];
|
const resetRegion = ids['slide-animation-reset-region'];
|
||||||
const resetAll = ids['slide-animation-reset-all'];
|
const resetAll = ids['slide-animation-reset-all'];
|
||||||
assert.equal(resetRegion.disabled, true);
|
assert.equal(resetRegion.disabled, true);
|
||||||
@@ -438,6 +455,7 @@ test('slide advanced popup applies changes, cancels drafts and preserves the par
|
|||||||
assert.equal(JSON.parse(applied.hero).intro.preset, 'none');
|
assert.equal(JSON.parse(applied.hero).intro.preset, 'none');
|
||||||
assert.equal(JSON.parse(applied.hero).outro.preset, 'fadeOut');
|
assert.equal(JSON.parse(applied.hero).outro.preset, 'fadeOut');
|
||||||
assert.equal(JSON.parse(applied.hero).outro.duration_ms, 1500);
|
assert.equal(JSON.parse(applied.hero).outro.duration_ms, 1500);
|
||||||
|
assert.equal(applied.fixed_brand, undefined);
|
||||||
assert.deepEqual(scrollBarCalls, [
|
assert.deepEqual(scrollBarCalls, [
|
||||||
['hide', ids['slide-animation-modal']],
|
['hide', ids['slide-animation-modal']],
|
||||||
['reset', ids['slide-animation-modal']]
|
['reset', ids['slide-animation-modal']]
|
||||||
@@ -618,4 +636,55 @@ test('shared animation route is protected and updates template animations withou
|
|||||||
assert.ok(calls.some(call => call[0] === 'commit'));
|
assert.ok(calls.some(call => call[0] === 'commit'));
|
||||||
assert.ok(calls.some(call => call[0] === 'notify'));
|
assert.ok(calls.some(call => call[0] === 'notify'));
|
||||||
assert.ok(calls.some(call => call[0] === 'thumbnail'));
|
assert.ok(calls.some(call => call[0] === 'thumbnail'));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('editable regions inherit template defaults until a slide supplies an override', async function () {
|
||||||
|
const pool = {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM c_templates')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]];
|
||||||
|
if (sql.includes('FROM c_template_regions')) return [[{
|
||||||
|
id: 11, template_id: 7, region_key: 'hero', region_type: 'text', default_content: { type: 'text', value: 'Template default' }, read_only: 0,
|
||||||
|
x: 10, y: 20, width: 300, height: 100
|
||||||
|
}]];
|
||||||
|
throw new Error('Unexpected query: ' + sql);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const initial = await buildSlidePayload(pool, { body: { title: 'Example', template_id: '7' } }, null);
|
||||||
|
assert.equal(JSON.parse(initial.contentJson).hero.value, 'Template default');
|
||||||
|
const overridden = await buildSlidePayload(pool, { body: { title: 'Example', template_id: '7', region_text_11: 'Slide override' } }, null);
|
||||||
|
assert.equal(JSON.parse(overridden.contentJson).hero.value, 'Slide override');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('read-only regions are excluded from slide content and layout overrides', async function () {
|
||||||
|
const pool = {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM c_templates')) return [[{ id: 7, canvas_size_width: 1920, canvas_size_height: 1080 }]];
|
||||||
|
if (sql.includes('FROM c_template_regions')) return [[{
|
||||||
|
id: 11, template_id: 7, region_key: 'brand', region_type: 'text', default_content: { type: 'text', value: 'Fixed brand' }, read_only: 1,
|
||||||
|
x: 10, y: 20, width: 300, height: 100
|
||||||
|
}]];
|
||||||
|
throw new Error('Unexpected query: ' + sql);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
const result = await buildSlidePayload(pool, { body: {
|
||||||
|
title: 'Example', template_id: '7', region_text_11: 'Client override',
|
||||||
|
region_layouts_json: JSON.stringify({ brand: {
|
||||||
|
x: 100, y: 100, width: 200, height: 80,
|
||||||
|
animation_json: { intro: { preset: 'bounceIn' } }
|
||||||
|
} })
|
||||||
|
} }, null);
|
||||||
|
assert.deepEqual(JSON.parse(result.contentJson), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('thumbnail markup uses template defaults for read-only regions', function () {
|
||||||
|
const html = buildThumbnailPreviewMarkup({
|
||||||
|
template: {
|
||||||
|
regions: [{ region_key: 'brand', region_type: 'image', read_only: 1, default_content: { value: '/media/brand.png' }, x: 0, y: 0, width: 100, height: 100 }],
|
||||||
|
canvas_size_width: 100,
|
||||||
|
canvas_size_height: 100
|
||||||
|
},
|
||||||
|
content: { brand: { value: '/media/old-brand.png' } }
|
||||||
|
}, 'http://localhost');
|
||||||
|
assert.match(html, /\/media\/brand\.png/);
|
||||||
|
assert.doesNotMatch(html, /old-brand/);
|
||||||
});
|
});
|
||||||
@@ -203,4 +203,16 @@ test('a partial timetable schema does not fast-forward migration detection', asy
|
|||||||
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.16'; }));
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.16'; }));
|
||||||
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.17'; }));
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.17'; }));
|
||||||
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.18'; }));
|
assert.ok(pendingMigrations.some(function (migration) { return migration.version === '2.6.18'; }));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('template region defaults migration upgrades a 2.17.5 installation', async () => {
|
||||||
|
const pool = createPool([]);
|
||||||
|
const pending = await getPendingMigrations(pool, { currentVersion: '2.17.5' });
|
||||||
|
const regionContentMigration = pending.find(migration => migration.version === '2.18.0');
|
||||||
|
|
||||||
|
assert.ok(regionContentMigration);
|
||||||
|
await regionContentMigration.run(pool);
|
||||||
|
|
||||||
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN default_content_json JSON NULL')));
|
||||||
|
assert.ok(pool.queries.some(([sql]) => String(sql).includes('ALTER TABLE c_template_regions ADD COLUMN read_only TINYINT(1) NOT NULL DEFAULT 0')));
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,7 @@ const vm = require('node:vm');
|
|||||||
|
|
||||||
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
|
const slideFormEditorSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form-editor.js'), 'utf8');
|
||||||
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
|
const slideFormSource = fs.readFileSync(require.resolve('../src/web/public/js/slides/slide-form.js'), 'utf8');
|
||||||
|
const slideFormTemplateSource = fs.readFileSync(require.resolve('../src/web/views/signage/slides/form.hbs'), 'utf8');
|
||||||
const mediaPickerSource = fs.readFileSync(require.resolve('../src/web/public/js/media-picker.js'), 'utf8');
|
const mediaPickerSource = fs.readFileSync(require.resolve('../src/web/public/js/media-picker.js'), 'utf8');
|
||||||
const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
|
const slideThumbnailPreviewSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnail-preview.js'), 'utf8');
|
||||||
const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
|
const slideThumbnailsSource = fs.readFileSync(require.resolve('../src/web/lib/media/slide-thumbnails.js'), 'utf8');
|
||||||
@@ -79,6 +80,12 @@ test('media library selections keep image and video remove controls', () => {
|
|||||||
assert.ok(mediaPickerSource.includes("var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image';"));
|
assert.ok(mediaPickerSource.includes("var removeAction = isVideo ? 'region-video' : isQrImage ? 'region-qr-image' : 'region-image';"));
|
||||||
assert.ok(mediaPickerSource.includes('data-remove-\' + removeAction'));
|
assert.ok(mediaPickerSource.includes('data-remove-\' + removeAction'));
|
||||||
assert.ok(mediaPickerSource.includes('slide-image-region-preview-remove'));
|
assert.ok(mediaPickerSource.includes('slide-image-region-preview-remove'));
|
||||||
|
assert.ok(mediaPickerSource.includes("document.querySelectorAll('.modal.show')"));
|
||||||
|
assert.ok(mediaPickerSource.includes('nestedParentFocusTrap.deactivate()'));
|
||||||
|
assert.ok(mediaPickerSource.includes('pickerZIndex - 5'));
|
||||||
|
const pickerModal = fs.readFileSync(require.resolve('../src/web/views/shared/media-picker-modal.hbs'), 'utf8');
|
||||||
|
assert.ok(pickerModal.indexOf('data-media-picker-confirm') < pickerModal.indexOf('data-media-picker-cancel'));
|
||||||
|
assert.match(pickerModal, /class="btn btn-warning" data-media-picker-cancel>Cancel/);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('slide thumbnail previews treat image-only text as visible content', () => {
|
test('slide thumbnail previews treat image-only text as visible content', () => {
|
||||||
@@ -146,4 +153,28 @@ test('HTML editor shares bounded size controls without changing its content', as
|
|||||||
assert.equal(source.value, '<div>Keep this HTML</div>');
|
assert.equal(source.value, '<div>Keep this HTML</div>');
|
||||||
assert.match(slideHtmlRegionSource, /style="resize:none;"/);
|
assert.match(slideHtmlRegionSource, /style="resize:none;"/);
|
||||||
assert.match(slideHtmlRegionSource, /sandboxed iframe preview\.<\/div>'\s*\+\s*'<div class="btn-group/);
|
assert.match(slideHtmlRegionSource, /sandboxed iframe preview\.<\/div>'\s*\+\s*'<div class="btn-group/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('slide template chooser searches and previews templates while preserving the submitted select', () => {
|
||||||
|
assert.equal((slideFormTemplateSource.match(/modalId="template-picker-modal"/g) || []).length, 1);
|
||||||
|
assert.match(slideFormTemplateSource, /name="template_id" id="template-select" class="visually-hidden"/);
|
||||||
|
assert.match(slideFormTemplateSource, /id="template-picker-open"/);
|
||||||
|
assert.match(slideFormTemplateSource, /template-picker-current"><strong id="template-picker-selected-name"/);
|
||||||
|
assert.doesNotMatch(slideFormTemplateSource, /id="template-picker-selected-size"/);
|
||||||
|
assert.match(slideFormTemplateSource, /modalId="template-picker-modal"/);
|
||||||
|
assert.match(slideFormTemplateSource, /id="template-picker-search"/);
|
||||||
|
assert.match(slideFormTemplateSource, /id="template-picker-canvas-size"/);
|
||||||
|
assert.match(slideFormTemplateSource, /All canvas sizes/);
|
||||||
|
assert.match(slideFormTemplateSource, /id="template-picker-grid"/);
|
||||||
|
assert.doesNotMatch(slideFormTemplateSource, /id="template-picker-none"|>No template<\/button>/);
|
||||||
|
assert.ok(slideFormSource.includes('function createTemplatePickerCard(template)'));
|
||||||
|
assert.ok(slideFormSource.includes('function populateTemplateCanvasSizes()'));
|
||||||
|
assert.doesNotMatch(slideFormSource, /createTemplatePickerCard\(null\)/);
|
||||||
|
assert.ok(slideFormSource.includes('function renderTemplatePicker()'));
|
||||||
|
assert.ok(slideFormSource.includes("templateSelect.dispatchEvent(new Event('change', { bubbles: true }))"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test('popup preview is disabled and guarded until a template is selected', () => {
|
||||||
|
assert.match(slideFormSource, /if \(openPreviewPopupButton\) openPreviewPopupButton\.disabled = !template;/);
|
||||||
|
assert.match(slideFormSource, /function openPreviewPopup\(\) \{\s*if \(!getSelectedTemplate\(\)\)\s*\{\s*return;/);
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
|
||||||
|
const { getDataSourceUsageMaps: getRssUsage } = require('../src/web/routes/data-sources/rss-feeds/routes');
|
||||||
|
const { getDataSourceUsageMaps: getTimetableUsage } = require('../src/web/routes/data-sources/timetables/routes');
|
||||||
|
const { getWeatherLocationUsageIds } = require('../src/web/routes/data-sources/weather');
|
||||||
|
const { isWeatherIconSetUsed } = require('../src/web/routes/settings/weather-icons');
|
||||||
|
|
||||||
|
function createPool(slideContent, templateContent) {
|
||||||
|
return {
|
||||||
|
async query(sql) {
|
||||||
|
if (sql.includes('FROM c_slides')) return [[{ content_json: JSON.stringify(slideContent) }]];
|
||||||
|
return [[{ default_content_json: JSON.stringify(templateContent) }]];
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const common = {
|
||||||
|
parseJsonSafe(value) {
|
||||||
|
if (typeof value === 'string') return JSON.parse(value);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
test('data-source and weather-icon usage includes template default content', async function () {
|
||||||
|
const pool = createPool(
|
||||||
|
{ slide: { feed_id: 1, timetable_group_id: 3, weather_location_id: 5, weather_icon_set_id: 'classic' } },
|
||||||
|
{ feed_id: 2, timetable_group_id: 4, weather_location_id: 6, weather_icon_set_id: 'template-icons' }
|
||||||
|
);
|
||||||
|
|
||||||
|
const rssUsage = await getRssUsage(pool, common);
|
||||||
|
const timetableUsage = await getTimetableUsage(pool, common);
|
||||||
|
const weatherUsage = await getWeatherLocationUsageIds(pool, common);
|
||||||
|
|
||||||
|
assert.deepEqual(Array.from(rssUsage).sort((a, b) => a - b), [1, 2]);
|
||||||
|
assert.deepEqual(Array.from(timetableUsage).sort((a, b) => a - b), [3, 4]);
|
||||||
|
assert.deepEqual(Array.from(weatherUsage).sort((a, b) => a - b), [5, 6]);
|
||||||
|
assert.equal(await isWeatherIconSetUsed(pool, 'classic'), true);
|
||||||
|
assert.equal(await isWeatherIconSetUsed(pool, 'template-icons'), true);
|
||||||
|
assert.equal(await isWeatherIconSetUsed(pool, 'unused'), false);
|
||||||
|
});
|
||||||
@@ -29,6 +29,8 @@ test('extractTemplateRegions normalizes JSON regions and filters invalid rows',
|
|||||||
region_name: 'Hero',
|
region_name: 'Hero',
|
||||||
region_type: 'TEXT',
|
region_type: 'TEXT',
|
||||||
lock_ratio: '16 : 9',
|
lock_ratio: '16 : 9',
|
||||||
|
default_content: '{"type":"text","value":"Template headline"}',
|
||||||
|
read_only: 'true',
|
||||||
animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
|
animation_json: '{"intro":{"preset":"fadeIn"},"out":{"preset":"bounceOut"}}',
|
||||||
x: '10',
|
x: '10',
|
||||||
y: '20',
|
y: '20',
|
||||||
@@ -48,6 +50,8 @@ test('extractTemplateRegions normalizes JSON regions and filters invalid rows',
|
|||||||
region_type: 'TEXT',
|
region_type: 'TEXT',
|
||||||
label: 'Hero',
|
label: 'Hero',
|
||||||
lock_ratio: '16:9',
|
lock_ratio: '16:9',
|
||||||
|
default_content: { type: 'text', value: 'Template headline' },
|
||||||
|
read_only: true,
|
||||||
animation_json: {
|
animation_json: {
|
||||||
intro: { preset: 'fadeIn' },
|
intro: { preset: 'fadeIn' },
|
||||||
outro: { preset: 'bounceOut' },
|
outro: { preset: 'bounceOut' },
|
||||||
@@ -112,6 +116,8 @@ test('buildTemplatePayload resolves canvas size and rejects duplicate region nam
|
|||||||
region_type: 'text',
|
region_type: 'text',
|
||||||
label: 'Header',
|
label: 'Header',
|
||||||
lock_ratio: '4:3',
|
lock_ratio: '4:3',
|
||||||
|
default_content: {},
|
||||||
|
read_only: false,
|
||||||
animation_json: {
|
animation_json: {
|
||||||
intro: { preset: 'none' },
|
intro: { preset: 'none' },
|
||||||
outro: { preset: 'none' },
|
outro: { preset: 'none' },
|
||||||
@@ -198,6 +204,26 @@ test('template create page renders the background remove control', () => {
|
|||||||
assert.match(html, /data-media-picker-hidden="existing-background-image-path"/);
|
assert.match(html, /data-media-picker-hidden="existing-background-image-path"/);
|
||||||
assert.match(html, /data-media-picker-file-input="background-image"/);
|
assert.match(html, /data-media-picker-file-input="background-image"/);
|
||||||
assert.match(html, /id="background-image"/);
|
assert.match(html, /id="background-image"/);
|
||||||
|
assert.match(html, /data-region-default-content/);
|
||||||
|
assert.match(html, /data-region-default-content-button/);
|
||||||
|
assert.match(html, /data-region-read-only/);
|
||||||
|
assert.match(html, /form-check form-switch d-inline-flex align-items-center gap-2 mb-0 ps-0 text-nowrap flex-shrink-0/);
|
||||||
|
assert.match(html, /form-check-input m-0 flex-shrink-0/);
|
||||||
|
assert.match(html, /<span class="form-check-label">Template-only<\/span>/);
|
||||||
|
assert.match(html, /title="Hidden in the slide editor\. Edit this content on the template\."/);
|
||||||
|
assert.match(html, /title="Edit template default content"><i class="bi bi-pencil-square me-1" aria-hidden="true"><\/i>Edit default/);
|
||||||
|
assert.match(html, /id="region-default-content-modal"/);
|
||||||
|
assert.match(html, /modal-xl modal-dialog-centered modal-dialog-scrollable template-default-content-dialog/);
|
||||||
|
assert.match(html, /id="region-default-content-editor-host"/);
|
||||||
|
assert.match(html, /id="region-default-content-preview-canvas"/);
|
||||||
|
assert.match(html, /d-flex align-items-center gap-3 border-top/);
|
||||||
|
assert.ok(html.indexOf('id="region-default-content-editor-host"') < html.indexOf('id="region-default-content-preview-stage"'));
|
||||||
|
assert.ok(html.indexOf('id="region-default-content-save"') < html.lastIndexOf('>Cancel</button>'));
|
||||||
|
assert.match(html, /class="btn btn-warning" data-bs-dismiss="modal">Cancel<\/button>/);
|
||||||
|
assert.match(html, /template-default-content-editor\.js/);
|
||||||
|
assert.match(html, /id="template-region-editor-data"/);
|
||||||
|
assert.match(html, /vendor\/tinymce\/tinymce\.min\.js/);
|
||||||
|
assert.ok(html.indexOf('name="region_width[]"') < html.indexOf('data-region-default-content-button'));
|
||||||
assert.match(html, /placeholder="type_1"/);
|
assert.match(html, /placeholder="type_1"/);
|
||||||
assert.match(html, /name="region_z\[\]" min="1" step="1" value="1" required/);
|
assert.match(html, /name="region_z\[\]" min="1" step="1" value="1" required/);
|
||||||
assert.match(html, /Save and Close/);
|
assert.match(html, /Save and Close/);
|
||||||
@@ -329,6 +355,8 @@ test('template animation durations use percent in the advanced modal', () => {
|
|||||||
|
|
||||||
assert.match(modal, /signage\/templates\/animation-advanced-fields/);
|
assert.match(modal, /signage\/templates\/animation-advanced-fields/);
|
||||||
assert.match(modal, /data-animation-modal-title>Advanced animations:/);
|
assert.match(modal, /data-animation-modal-title>Advanced animations:/);
|
||||||
|
assert.ok(modal.indexOf('data-animation-modal-apply') < modal.lastIndexOf('>Cancel</button>'));
|
||||||
|
assert.match(modal, /class="btn btn-warning" data-bs-dismiss="modal">Cancel<\/button>/);
|
||||||
assert.ok(script.includes("title.textContent = 'Advanced animations: ' + (label ? label.textContent : 'Region');"));
|
assert.ok(script.includes("title.textContent = 'Advanced animations: ' + (label ? label.textContent : 'Region');"));
|
||||||
assert.doesNotMatch(modal, /modalBackdropStatic=true/);
|
assert.doesNotMatch(modal, /modalBackdropStatic=true/);
|
||||||
assert.match(advancedFields, /Duration %/);
|
assert.match(advancedFields, /Duration %/);
|
||||||
@@ -505,4 +533,55 @@ test('template snap toggle places its switch after the label', () => {
|
|||||||
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
|
const css = fs.readFileSync(require.resolve('../src/web/public/css/theme-custom.css'), 'utf8');
|
||||||
assert.match(template, /class="form-check form-switch template-snap-control[^>]*"><label for="template-layout-snap"[^>]*>Snap to guides<\/label><input type="checkbox" id="template-layout-snap"/);
|
assert.match(template, /class="form-check form-switch template-snap-control[^>]*"><label for="template-layout-snap"[^>]*>Snap to guides<\/label><input type="checkbox" id="template-layout-snap"/);
|
||||||
assert.match(css, /\.template-snap-control \.form-check-input\s*\{[^}]*order:\s*2/);
|
assert.match(css, /\.template-snap-control \.form-check-input\s*\{[^}]*order:\s*2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('template default content adapter reuses slide region helpers and TinyMCE controller', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-default-content-editor.js'), 'utf8');
|
||||||
|
assert.match(source, /createSlideFormRegionHelpers/);
|
||||||
|
assert.match(source, /getRegionEditorCardHtml/);
|
||||||
|
assert.match(source, /getPreviewRegionContent/);
|
||||||
|
assert.match(source, /template-default-content-preview-label/);
|
||||||
|
assert.match(source, /createSlideFormEditorController/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('template designer restores region default content and read-only state from saved data', () => {
|
||||||
|
const source = fs.readFileSync(require.resolve('../src/web/public/js/templates/template-designer.js'), 'utf8');
|
||||||
|
const match = source.match(/ function populateRegionCard\([^]*?\n }/);
|
||||||
|
assert.ok(match);
|
||||||
|
|
||||||
|
const fields = {
|
||||||
|
'[data-region-chip]': {},
|
||||||
|
'[data-region-title]': {},
|
||||||
|
'[name="region_name[]"]': {},
|
||||||
|
'[name="region_type[]"]': {},
|
||||||
|
'[name="region_key[]"]': {},
|
||||||
|
'[name="region_label[]"]': {},
|
||||||
|
'[data-region-default-content]': {},
|
||||||
|
'[data-region-read-only]': {},
|
||||||
|
'[name="region_lock_ratio[]"]': {},
|
||||||
|
'[name="region_x[]"]': {},
|
||||||
|
'[name="region_y[]"]': {},
|
||||||
|
'[name="region_z[]"]': {},
|
||||||
|
'[name="region_width[]"]': {},
|
||||||
|
'[name="region_height[]"]': {}
|
||||||
|
};
|
||||||
|
const context = vm.createContext({
|
||||||
|
getRegionChipLabel: () => 'HTML',
|
||||||
|
normalizeLockRatio: value => value || '',
|
||||||
|
writeAnimationConfig() {},
|
||||||
|
valueOrDefault: (value, fallback) => value === undefined || value === null || value === '' ? fallback : value,
|
||||||
|
updateRegionLockRatioState() {},
|
||||||
|
updateRegionLockBadge() {}
|
||||||
|
});
|
||||||
|
vm.runInContext(match[0], context);
|
||||||
|
context.populateRegionCard({ querySelector: selector => fields[selector] || null }, {
|
||||||
|
region_key: 'brand_edge', region_type: 'html', label: 'brand_edge',
|
||||||
|
default_content: { type: 'html', value: '<div>Template-owned</div>' },
|
||||||
|
read_only: 1, x: 0, y: 0, width: 1920, height: 7, z_index: 1
|
||||||
|
});
|
||||||
|
|
||||||
|
assert.deepEqual(JSON.parse(fields['[data-region-default-content]'].value), {
|
||||||
|
type: 'html', value: '<div>Template-owned</div>'
|
||||||
|
});
|
||||||
|
assert.equal(fields['[data-region-read-only]'].checked, true);
|
||||||
});
|
});
|
||||||
@@ -22,12 +22,14 @@ test('upload reference collection includes WYSIWYG images across slide regions',
|
|||||||
clock: { value: '<p><img src="http://localhost:8080/media/uploads/clock-image.png"></p>' },
|
clock: { value: '<p><img src="http://localhost:8080/media/uploads/clock-image.png"></p>' },
|
||||||
html: { value: '<p><img src="/media/uploads/html-image.png"></p>' },
|
html: { value: '<p><img src="/media/uploads/html-image.png"></p>' },
|
||||||
rss: { value: '<p><img src="/media/uploads/rss-image.png"></p>' }
|
rss: { value: '<p><img src="/media/uploads/rss-image.png"></p>' }
|
||||||
})
|
}),
|
||||||
|
regions: [{ default_content: { value: '<p><img src="/media/uploads/template-image.png"></p>' } }]
|
||||||
})).sort(),
|
})).sort(),
|
||||||
[
|
[
|
||||||
'/media/uploads/clock-image.png',
|
'/media/uploads/clock-image.png',
|
||||||
'/media/uploads/html-image.png',
|
'/media/uploads/html-image.png',
|
||||||
'/media/uploads/rss-image.png'
|
'/media/uploads/rss-image.png',
|
||||||
|
'/media/uploads/template-image.png'
|
||||||
].sort()
|
].sort()
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in new issue
Block a user