309 lines
12 KiB
JavaScript
309 lines
12 KiB
JavaScript
// Template data access helpers and region normalization logic.
|
|
|
|
const { parseJsonSafe, readFormArray, validateMaxLength } = require('./utils');
|
|
const animationPresets = require('../web/public/js/templates/animation-presets');
|
|
|
|
const TEMPLATE_NAME_MAX_LENGTH = 255;
|
|
const REGION_NAME_MAX_LENGTH = 255;
|
|
|
|
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 normalizeBackgroundGradient(value) {
|
|
let gradient = value;
|
|
if (typeof gradient === 'string') {
|
|
try {
|
|
gradient = JSON.parse(gradient);
|
|
} catch (_error) {
|
|
gradient = null;
|
|
}
|
|
}
|
|
if (!gradient || typeof gradient !== 'object' || Array.isArray(gradient)) {
|
|
return null;
|
|
}
|
|
const sourceStops = Array.isArray(gradient.stops) && gradient.stops.length
|
|
? gradient.stops
|
|
: (Array.isArray(gradient.colors) ? gradient.colors.map((color, index, colors) => ({
|
|
color,
|
|
position: colors.length > 1 ? Math.round((index / (colors.length - 1)) * 100) : 0
|
|
})) : []);
|
|
const stops = sourceStops.slice(0, 12).map((stop) => ({
|
|
color: sanitizeBackgroundColor(stop && stop.color),
|
|
position: Math.max(0, Math.min(100, Number.isFinite(Number(stop && stop.position)) ? Number(stop.position) : 0))
|
|
}));
|
|
if (stops.length < 2) {
|
|
return null;
|
|
}
|
|
const angle = Number(gradient.angle);
|
|
return JSON.stringify({
|
|
type: 'linear',
|
|
stops,
|
|
angle: Number.isFinite(angle) ? Math.max(0, Math.min(360, angle)) : 90
|
|
});
|
|
}
|
|
|
|
function normalizeTemplateRegionType(value) {
|
|
const rawType = String(value || 'text').trim();
|
|
return rawType || 'text';
|
|
}
|
|
|
|
function normalizeTemplateRegionLockRatio(value) {
|
|
const rawRatio = String(value || '').trim();
|
|
if (!/^\d+\s*:\s*\d+$/.test(rawRatio)) {
|
|
return null;
|
|
}
|
|
return rawRatio.replace(/\s+/g, '');
|
|
}
|
|
|
|
function normalizeTemplateRegionAnimation(value) {
|
|
if (animationPresets && typeof animationPresets.normalize === 'function') {
|
|
return animationPresets.normalize(value);
|
|
}
|
|
|
|
const allowed = new Set(
|
|
Array.isArray(animationPresets && animationPresets.allValues) && animationPresets.allValues.length
|
|
? animationPresets.allValues
|
|
: ['none', 'fadeIn', 'fadeInDown', 'fadeInLeft', 'fadeInRight', 'fadeInUp', 'zoomIn', 'zoomInDown', 'zoomInLeft', 'zoomInRight', 'zoomInUp', 'slideInDown', 'slideInLeft', 'slideInRight', 'slideInUp', 'fadeOut', 'fadeOutDown', 'fadeOutLeft', 'fadeOutRight', 'fadeOutUp', 'zoomOut', 'zoomOutDown', 'zoomOutLeft', 'zoomOutRight', 'zoomOutUp', 'slideOutDown', 'slideOutLeft', 'slideOutRight', 'slideOutUp', 'backInDown', 'backInLeft', 'backInRight', 'backInUp', 'backOutDown', 'backOutLeft', 'backOutRight', 'backOutUp', 'bounceIn', 'bounceInDown', 'bounceInLeft', 'bounceInRight', 'bounceInUp', 'bounceOut', 'bounceOutDown', 'bounceOutLeft', 'bounceOutRight', 'bounceOutUp', 'flip', 'flipInX', 'flipInY', 'flipOutX', 'flipOutY', 'lightSpeedInLeft', 'lightSpeedInRight', 'lightSpeedOutLeft', 'lightSpeedOutRight', 'rotateIn', 'rotateInDownLeft', 'rotateInDownRight', 'rotateInUpLeft', 'rotateInUpRight', 'rotateOut', 'rotateOutDownLeft', 'rotateOutDownRight', 'rotateOutUpLeft', 'rotateOutUpRight', 'hinge', 'jackInTheBox', 'rollIn', 'rollOut', 'flash', 'pulse', 'rubberBand', 'shakeX', 'shakeY', 'headShake', 'swing', 'tada', 'wobble', 'jello', 'heartBeat', 'bounce']
|
|
);
|
|
const raw = String(value || '').trim().toLowerCase();
|
|
return allowed.has(raw) ? raw : 'none';
|
|
}
|
|
|
|
function normalizeTemplateRegionAnimationStep(value, fallbackPreset) {
|
|
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
const normalized = {};
|
|
Object.keys(value).forEach((key) => {
|
|
normalized[key] = value[key];
|
|
});
|
|
normalized.preset = normalizeTemplateRegionAnimation(value.preset !== undefined ? value.preset : fallbackPreset || 'none');
|
|
return normalized;
|
|
}
|
|
|
|
return {
|
|
preset: normalizeTemplateRegionAnimation(typeof value === 'string' ? value : fallbackPreset || 'none')
|
|
};
|
|
}
|
|
|
|
function normalizeTemplateRegionAnimationConfig(value) {
|
|
let raw = value;
|
|
if (typeof raw === 'string') {
|
|
const text = String(raw || '').trim();
|
|
if (!text) {
|
|
raw = null;
|
|
} else {
|
|
try {
|
|
raw = JSON.parse(text);
|
|
} catch (_error) {
|
|
raw = null;
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
raw = {};
|
|
}
|
|
|
|
return {
|
|
intro: normalizeTemplateRegionAnimationStep(raw.intro, 'none'),
|
|
outro: normalizeTemplateRegionAnimationStep(raw.outro !== undefined ? raw.outro : raw.out, 'none'),
|
|
loop: normalizeTemplateRegionAnimationStep(raw.loop, 'none')
|
|
};
|
|
}
|
|
|
|
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.background_gradient, 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 c_templates st
|
|
LEFT JOIN c_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, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions WHERE template_id = ? ORDER BY z_index ASC, id ASC', [id]);
|
|
template.regions = regions;
|
|
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.background_gradient, 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 c_templates st
|
|
LEFT JOIN c_canvas_sizes cs ON cs.id = st.canvas_size_id
|
|
ORDER BY st.id DESC
|
|
`);
|
|
const [templateRegions] = await pool.query('SELECT id, template_id, region_key, region_type, label, lock_ratio, animation_json, x, y, width, height, z_index, created_at, modified_at, created_by, modified_by FROM c_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
|
|
return { templates, templateRegions };
|
|
}
|
|
|
|
function extractTemplateRegions(body) {
|
|
const regionsJson = body.regions_json;
|
|
if (regionsJson) {
|
|
const parsed = parseJsonSafe(regionsJson);
|
|
if (Array.isArray(parsed)) {
|
|
return parsed.map((region) => {
|
|
const regionType = normalizeTemplateRegionType(region.region_type);
|
|
const regionName = validateMaxLength(region.region_name || region.region_key || region.label || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
|
return {
|
|
region_key: regionName,
|
|
region_type: regionType,
|
|
label: regionName,
|
|
lock_ratio: normalizeTemplateRegionLockRatio(region.lock_ratio),
|
|
animation_json: normalizeTemplateRegionAnimationConfig(region.animation_json),
|
|
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 ratios = readFormArray(body, 'region_lock_ratio[]');
|
|
const animationJsons = readFormArray(body, 'region_animation_json[]');
|
|
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 regions = [];
|
|
|
|
for (let i = 0; i < keys.length; i += 1) {
|
|
const name = validateMaxLength(names[i] || keys[i] || labels[i] || '', REGION_NAME_MAX_LENGTH, 'Region name');
|
|
const rawType = String(types[i] || 'text').trim();
|
|
const regionType = normalizeTemplateRegionType(rawType);
|
|
if (!name) {
|
|
continue;
|
|
}
|
|
regions.push({
|
|
region_key: name,
|
|
region_type: regionType,
|
|
label: name,
|
|
lock_ratio: normalizeTemplateRegionLockRatio(ratios[i]),
|
|
animation_json: normalizeTemplateRegionAnimationConfig(animationJsons[i]),
|
|
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 = validateMaxLength(req.body.name || '', TEMPLATE_NAME_MAX_LENGTH, 'Template name');
|
|
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 submittedBackgroundGradient = Object.prototype.hasOwnProperty.call(req.body, 'background_gradient')
|
|
? req.body.background_gradient
|
|
: existingTemplate && existingTemplate.background_gradient;
|
|
const backgroundGradient = normalizeBackgroundGradient(submittedBackgroundGradient);
|
|
const backgroundImagePath = backgroundImage
|
|
? `/media/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 c_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;
|
|
}
|
|
}
|
|
|
|
ensureUniqueTemplateRegionNames(regions);
|
|
|
|
return {
|
|
name,
|
|
canvasSizeId: resolvedCanvasSizeId,
|
|
canvasSizeWidth: canvasWidth,
|
|
canvasSizeHeight: canvasHeight,
|
|
backgroundImagePath,
|
|
backgroundColor,
|
|
backgroundGradient,
|
|
regions
|
|
};
|
|
}
|
|
|
|
module.exports = {
|
|
fetchTemplateById,
|
|
fetchTemplatesData,
|
|
extractTemplateRegions,
|
|
buildTemplatePayload,
|
|
normalizeBackgroundGradient,
|
|
parseJsonSafe
|
|
};
|