Initial Product release

This commit is contained in:
2026-07-13 21:44:49 +01:00
parent 2d3ff69c6e
commit 0837f4f529
84 changed files with 13174 additions and 36 deletions
+38
View File
@@ -0,0 +1,38 @@
async function fetchAdminData(pool) {
const [playlists] = await pool.query('SELECT * FROM playlists ORDER BY id DESC');
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, 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 * FROM slide_template_regions ORDER BY template_id ASC, z_index ASC, id ASC');
const [slides] = await pool.query(`
SELECT s.*, st.name AS template_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY s.id DESC
`);
const [screens] = await pool.query(`
SELECT s.*, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
ORDER BY s.id DESC
`);
const [playlistSlides] = await pool.query(`
SELECT ps.id, ps.playlist_id, ps.position, ps.duration_seconds, ps.schedule_mode, ps.schedule_start_datetime, ps.schedule_end_datetime, ps.schedule_start_time, ps.schedule_end_time, ps.schedule_days_json, sl.id AS slide_id, sl.title, sl.media_path, sl.media_type, cs.width AS canvas_width, cs.height AS canvas_height
FROM playlist_slides ps
JOIN slides sl ON sl.id = ps.slide_id
LEFT JOIN slide_templates st ON st.id = sl.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
ORDER BY ps.playlist_id ASC, ps.position ASC, ps.id ASC
`);
return { playlists, canvasSizes, templates, templateRegions, slides, screens, playlistSlides };
}
module.exports = {
fetchAdminData
};
+33
View File
@@ -0,0 +1,33 @@
async function fetchCanvasSizesData(pool) {
const [canvasSizes] = await pool.query('SELECT * FROM canvas_sizes ORDER BY width ASC, height ASC, name ASC');
return { canvasSizes };
}
async function fetchCanvasSizeById(pool, id) {
const [rows] = await pool.query('SELECT * FROM canvas_sizes WHERE id = ?', [id]);
return rows[0] || null;
}
function buildCanvasSizePayload(req, existingCanvasSize) {
const name = String(req.body.name || '').trim();
const width = Math.max(1, Number(req.body.width || (existingCanvasSize && existingCanvasSize.width) || 0));
const height = Math.max(1, Number(req.body.height || (existingCanvasSize && existingCanvasSize.height) || 0));
if (!name) {
const error = new Error('Canvas size name is required.');
error.statusCode = 400;
throw error;
}
return {
name,
width,
height
};
}
module.exports = {
fetchCanvasSizesData,
fetchCanvasSizeById,
buildCanvasSizePayload
};
+26
View File
@@ -0,0 +1,26 @@
const { fetchAdminData } = require('./admin');
const { fetchPlaylistById } = require('./playlists');
const { slugify, uniqueScreenSlug, fetchScreenById, fetchScreenEditData } = require('./screens');
const { fetchTemplateById, fetchTemplatesData, extractTemplateRegions, buildTemplatePayload } = require('./templates');
const { fetchCanvasSizesData, fetchCanvasSizeById, buildCanvasSizePayload } = require('./canvas-sizes');
const { fetchSlideById, buildSlidePayload } = require('./slides');
const { parseJsonSafe } = require('./utils');
module.exports = {
slugify,
uniqueScreenSlug,
parseJsonSafe,
fetchAdminData,
fetchPlaylistById,
fetchScreenById,
fetchScreenEditData,
fetchTemplateById,
fetchSlideById,
fetchTemplatesData,
fetchCanvasSizesData,
fetchCanvasSizeById,
buildCanvasSizePayload,
buildSlidePayload,
extractTemplateRegions,
buildTemplatePayload
};
+8
View File
@@ -0,0 +1,8 @@
async function fetchPlaylistById(pool, id) {
const [rows] = await pool.query('SELECT * FROM playlists WHERE id = ?', [id]);
return rows[0] || null;
}
module.exports = {
fetchPlaylistById
};
+44
View File
@@ -0,0 +1,44 @@
function slugify(value) {
return String(value || '')
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.replace(/-{2,}/g, '-');
}
async function uniqueScreenSlug(pool, baseSlug) {
const start = baseSlug || `screen-${Date.now()}`;
let candidate = start;
let counter = 2;
while (true) {
const [rows] = await pool.query('SELECT id FROM screens WHERE slug = ?', [candidate]);
if (!rows.length) {
return candidate;
}
candidate = `${start}-${counter}`;
counter += 1;
}
}
async function fetchScreenById(pool, id) {
const [rows] = await pool.query(`
SELECT s.*, p.name AS playlist_name
FROM screens s
LEFT JOIN playlists p ON p.id = s.playlist_id
WHERE s.id = ?
`, [id]);
return rows[0] || null;
}
async function fetchScreenEditData(pool) {
const [playlists] = await pool.query('SELECT * FROM playlists ORDER BY id DESC');
return { playlists };
}
module.exports = {
slugify,
uniqueScreenSlug,
fetchScreenById,
fetchScreenEditData
};
+158
View File
@@ -0,0 +1,158 @@
const { fetchTemplateById } = require('./templates');
const { parseJsonSafe } = require('./utils');
const ALLOWED_RICH_TEXT_TAGS = ['b', 'strong', 'i', 'em', 'u', 'br', 'p', 'div', 'ul', 'ol', 'li'];
function sanitizeRichText(html) {
let output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
output = output.replace(/<style[\s\S]*?<\/style>/gi, '');
return output.replace(/<[^>]+>/g, (tag) => {
const match = tag.match(/^<\s*(\/?)\s*([a-z0-9]+)(?:\s[^>]*)?>$/i);
if (!match) {
return '';
}
const closing = Boolean(match[1]);
const name = String(match[2] || '').toLowerCase();
if (!ALLOWED_RICH_TEXT_TAGS.includes(name)) {
return '';
}
if (name === 'br') {
return '<br>';
}
return closing ? `</${name}>` : `<${name}>`;
});
}
async function fetchSlideById(pool, id) {
const [slides] = await pool.query(`
SELECT s.id, s.title, s.body, s.template_id, s.content_json, s.media_path, s.media_type, s.created_at, s.modified_at,
st.name AS template_name, cs.name AS canvas_size_name, cs.width AS canvas_width, cs.height AS canvas_height
FROM slides s
LEFT JOIN slide_templates st ON st.id = s.template_id
LEFT JOIN canvas_sizes cs ON cs.id = st.canvas_size_id
WHERE s.id = ?
`, [id]);
if (!slides.length) {
return null;
}
const slide = slides[0];
slide.content = parseJsonSafe(slide.content_json) || {};
if (slide.template_id) {
slide.template = await fetchTemplateById(pool, slide.template_id);
}
return slide;
}
function getFilesByField(files) {
const map = {};
(files || []).forEach((file) => {
map[file.fieldname] = file;
});
return map;
}
function sanitizeTextColor(value, fallback) {
const raw = String(value || '').trim();
if (/^#[0-9a-fA-F]{6}$/.test(raw) || /^#[0-9a-fA-F]{3}$/.test(raw)) {
return raw;
}
return fallback || '#000000';
}
function getTextRegionStyle(body, region, existingContent) {
const existing = existingContent && existingContent[region.region_key] ? existingContent[region.region_key] : {};
const fontFamily = String(body[`region_font_family_${region.id}`] || existing.font_family || region.font_family || 'Arial').trim() || 'Arial';
const fontSize = Math.max(8, Number(body[`region_font_size_${region.id}`] || existing.font_size || 24));
const fontColor = sanitizeTextColor(body[`region_font_color_${region.id}`] || existing.font_color || region.font_color || '#000000');
return {
font_family: fontFamily,
font_size: fontSize,
font_color: fontColor
};
}
function buildTemplateContent(template, body, filesByField, existingContent) {
const content = {};
template.regions.forEach((region) => {
if (region.region_type === 'image') {
const uploaded = filesByField[`region_image_${region.id}`];
const existing = body[`existing_region_image_${region.id}`];
content[region.region_key] = {
type: 'image',
value: uploaded ? `/uploads/${uploaded.filename}` : String(existing || '').trim() || ((existingContent && existingContent[region.region_key]) ? existingContent[region.region_key].value : '')
};
} else if (region.region_type === 'webpage') {
const submitted = body[`region_webpage_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
content[region.region_key] = {
type: 'webpage',
value: submitted === undefined ? current : String(submitted || '').trim()
};
} else if (region.region_type === 'html') {
const submitted = body[`region_html_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
content[region.region_key] = {
type: 'html',
value: submitted === undefined ? current : String(submitted || '')
};
} else {
const submitted = body[`region_text_${region.id}`];
const current = existingContent && existingContent[region.region_key] ? existingContent[region.region_key].value : '';
const style = getTextRegionStyle(body, region, existingContent);
content[region.region_key] = {
type: 'text',
value: submitted === undefined ? current : String(submitted),
font_family: style.font_family,
font_size: style.font_size,
font_color: style.font_color
};
}
});
return content;
}
async function buildSlidePayload(pool, req, existingSlide) {
const title = String(req.body.title || '').trim();
const templateId = req.body.template_id ? Number(req.body.template_id) : null;
const filesByField = getFilesByField(req.files || []);
const template = templateId ? await fetchTemplateById(pool, templateId) : null;
const existingContent = existingSlide ? parseJsonSafe(existingSlide.content_json) || {} : {};
if (!title) {
const error = new Error('Slide title is required.');
error.statusCode = 400;
throw error;
}
if (templateId && !template) {
const error = new Error('Template not found.');
error.statusCode = 400;
throw error;
}
if (template) {
return {
title,
body: existingSlide ? existingSlide.body : null,
templateId: template.id,
contentJson: JSON.stringify(buildTemplateContent(template, req.body, filesByField, existingContent)),
mediaPath: null,
mediaType: null
};
}
return {
title,
body: existingSlide ? existingSlide.body : null,
templateId: null,
contentJson: existingSlide ? existingSlide.content_json : null,
mediaPath: existingSlide ? existingSlide.media_path : null,
mediaType: existingSlide ? existingSlide.media_type : null
};
}
module.exports = {
fetchSlideById,
buildSlidePayload
};
+169
View File
@@ -0,0 +1,169 @@
const { parseJsonSafe, readFormArray } = require('./utils');
const ALLOWED_TEMPLATE_REGION_TYPES = ['text', 'image', 'webpage', 'html'];
function normalizeTemplateRegionType(value) {
const rawType = String(value || 'text').trim();
return ALLOWED_TEMPLATE_REGION_TYPES.includes(rawType) ? rawType : 'text';
}
async function fetchTemplateById(pool, id) {
const [templates] = await pool.query(`
SELECT st.id, st.name, st.canvas_size_id, st.background_image_path, 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 * 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.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 * 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 backgroundImagePath = backgroundImage
? `/uploads/${backgroundImage.filename}`
: 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 * 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
}];
}
return {
name,
canvasSizeId: resolvedCanvasSizeId,
canvasSizeWidth: canvasWidth,
canvasSizeHeight: canvasHeight,
backgroundImagePath,
regions
};
}
module.exports = {
fetchTemplateById,
fetchTemplatesData,
extractTemplateRegions,
buildTemplatePayload,
parseJsonSafe
};
+28
View File
@@ -0,0 +1,28 @@
function parseJsonSafe(value) {
if (!value) {
return null;
}
if (typeof value === 'object') {
return value;
}
try {
return JSON.parse(value);
} catch (_error) {
return null;
}
}
function readFormArray(body, key) {
if (Array.isArray(body[key])) {
return body[key];
}
if (body[key] === undefined) {
return [];
}
return [body[key]];
}
module.exports = {
parseJsonSafe,
readFormArray
};