Initial Product release
This commit is contained in:
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
@@ -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
|
||||
};
|
||||
Reference in New Issue
Block a user