Files
pulse-signage/src/data/templates.js
T
lzstealth 2ea8d389fa This PR breaks the large web and player bootstrap files into smaller modules with clearer ownership.
Web changes:

Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of web.js.
Kept web.js focused on wiring and server startup.
Fixed screen playlist reassignment so changing a screen’s playlist now triggers a refresh.
Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
Player changes:

Split websocket/runtime handling into runtime.js.
Split playlist assembly and revision hashing into playlist.js.
Split onboarding and player HTTP routes into dedicated modules.
Split render utilities and template loading into render-helpers.js.
Kept player.js mostly as startup/orchestration.
Validation:

Rebuilt both services with Docker Compose.
Smoke-checked web and player routes after the refactor.
Verified get_errors was clean on the touched modules.
2026-07-20 23:58:27 +01:00

207 lines
7.5 KiB
JavaScript

const { parseJsonSafe, readFormArray } = require('./utils');
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
function sanitizeBackgroundColor(value) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return '#111111';
}
function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim();
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
}
function normalizeTemplateRegionName(value) {
return String(value || '').trim();
}
function ensureUniqueTemplateRegionNames(regions) {
const seen = new Map();
for (let i = 0; i < regions.length; i += 1) {
const region = regions[i];
const regionName = normalizeTemplateRegionName(region.region_key || region.label);
if (!regionName) {
continue;
}
const normalized = regionName.toLowerCase();
if (seen.has(normalized)) {
const error = new Error('Region names must be unique on this template.');
error.statusCode = 400;
throw error;
}
seen.set(normalized, true);
}
}
async function fetchTemplateById(pool, id) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE st.id = ?
`, [id]);
if (!templates.length) {
return null;
}
const template = templates[0];
const [regions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
template.regions = regions;
return template;
}
async function fetchTemplatesData(pool) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, st.background_color, st.created_at, st.modified_at,
cs.name AS canvas_size_name, cs.width AS canvas_size_width, cs.height AS canvas_size_height
FROM slide_templates st
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY st.id DESC
`);
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, font_family, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
return { templates, templateRegions };
}
function extractTemplateRegions(body) {
const regionsJson = body.regions_json;
if (regionsJson) {
const parsed = parseJsonSafe(regionsJson);
if (Array.isArray(parsed)) {
return parsed.map((region) => ({
region_key: String(region.region_name || region.region_key || region.label || '').trim(),
region_type: normalizeTemplateRegionType(region.region_type),
label: String(region.region_name || region.label || region.region_key || '').trim(),
font_family: ['text', 'html'].includes(normalizeTemplateRegionType(region.region_type)) ? String(region.font_family || '').trim() || null : null,
x: Number(region.x || 0),
y: Number(region.y || 0),
width: Number(region.width || 100),
height: Number(region.height || 100),
z_index: Number(region.z_index || 0)
})).filter((region) => region.region_key && region.label);
}
}
const keys = readFormArray(body, 'region_key[]');
const names = readFormArray(body, 'region_name[]');
const labels = readFormArray(body, 'region_label[]');
const types = readFormArray(body, 'region_type[]');
const xs = readFormArray(body, 'region_x[]');
const ys = readFormArray(body, 'region_y[]');
const widths = readFormArray(body, 'region_width[]');
const heights = readFormArray(body, 'region_height[]');
const zs = readFormArray(body, 'region_z[]');
const fonts = readFormArray(body, 'font_family[]');
const regions = [];
for (let i = 0; i < keys.length; i += 1) {
const name = String(names[i] || keys[i] || labels[i] || '').trim();
const rawType = String(types[i] || 'text').trim();
const regionType = normalizeTemplateRegionType(rawType);
if (!name) {
continue;
}
regions.push({
region_key: name,
region_type: regionType,
label: name,
font_family: ['text', 'html'].includes(regionType) ? String(fonts[i] || 'Arial').trim() || 'Arial' : null,
x: Number(xs[i] || 0),
y: Number(ys[i] || 0),
width: Number(widths[i] || 100),
height: Number(heights[i] || 100),
z_index: Number(zs[i] || 0)
});
}
return regions;
}
function getFilesByField(files) {
const map = {};
(files || []).forEach((file) => {
map[file.fieldname] = file;
});
return map;
}
async function buildTemplatePayload(pool, req, existingTemplate) {
const name = String(req.body.name || '').trim();
const canvasSizeId = req.body.canvas_size_id ? Number(req.body.canvas_size_id) : null;
let canvasWidth = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_width) || 1920));
let canvasHeight = Math.max(1, Number((existingTemplate && existingTemplate.canvas_size_height) || 1080));
let regions = extractTemplateRegions(req.body);
const filesByField = getFilesByField(req.files || []);
const backgroundImage = filesByField.background_image;
const removeBackgroundImage = Boolean(req.body.remove_background_image);
const backgroundColor = sanitizeBackgroundColor(req.body.background_color || (existingTemplate && existingTemplate.background_color));
const backgroundImagePath = backgroundImage
? `/uploads/${backgroundImage.filename}`
: removeBackgroundImage
? null
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
if (!name) {
const error = new Error('Template name is required.');
error.statusCode = 400;
throw error;
}
let resolvedCanvasSizeId = canvasSizeId;
if (resolvedCanvasSizeId) {
const [canvasSizes] = await pool.query('SELECT id, name, width, height, created_at, modified_at, created_by, modified_by FROM canvas_sizes WHERE id = ?', [resolvedCanvasSizeId]);
const canvasSize = canvasSizes[0];
if (!canvasSize) {
const error = new Error('Canvas size not found.');
error.statusCode = 400;
throw error;
}
canvasWidth = Number(canvasSize.width);
canvasHeight = Number(canvasSize.height);
} else {
resolvedCanvasSizeId = null;
}
if (!regions.length) {
if (existingTemplate) {
const error = new Error('At least one region is required.');
error.statusCode = 400;
throw error;
}
regions = [{
region_key: 'region_1',
region_type: 'text',
label: 'Region 1',
font_family: 'Arial',
x: 120,
y: 120,
width: Math.max(200, Math.round(canvasWidth * 0.22)),
height: Math.max(120, Math.round(canvasHeight * 0.15)),
z_index: 1
}];
}
ensureUniqueTemplateRegionNames(regions);
return {
name,
canvasSizeId: resolvedCanvasSizeId,
canvasSizeWidth: canvasWidth,
canvasSizeHeight: canvasHeight,
backgroundImagePath,
backgroundColor,
regions
};
}
module.exports = {
fetchTemplateById,
fetchTemplatesData,
extractTemplateRegions,
buildTemplatePayload,
parseJsonSafe
};