Save worktree changes

This commit is contained in:
2026-07-25 02:29:19 +01:00
parent 8d3b7d557b
commit db9d718cd8
170 changed files with 11719 additions and 3414 deletions
@@ -0,0 +1,21 @@
const { renderView } = require('../../../view');
function buildDefaultCanvasSize() {
return {
id: null,
name: '',
width: 1920,
height: 1080
};
}
module.exports = function renderCanvasSizeFormPage(canvasSize, mode, message, currentUser) {
const isEdit = mode === 'edit';
return renderView(isEdit ? 'canvas-sizes/edit' : 'canvas-sizes/add', {
title: isEdit ? 'Edit canvas size' : 'Add canvas size',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSize: canvasSize || buildDefaultCanvasSize()
});
};
@@ -0,0 +1,5 @@
const renderCanvasSizeFormPage = require('./add');
module.exports = function renderCanvasSizeEditPage(canvasSize, _data, message, currentUser) {
return renderCanvasSizeFormPage(canvasSize, 'edit', message, currentUser);
};
@@ -0,0 +1,19 @@
const { renderView } = require('../../../view');
module.exports = function renderCanvasSizesPage(data, message, currentUser) {
const canvasSizes = (data.canvasSizes || []).map((size) => ({
id: size.id,
name: size.name,
width: size.width,
height: size.height,
templateCount: (data.templates || []).filter((template) => Number(template.canvas_size_id) === Number(size.id)).length
}));
return renderView('canvas-sizes/list', {
title: 'Canvas sizes',
active: 'canvas-sizes',
message: message,
currentUser: currentUser || null,
canvasSizes: canvasSizes
});
};
@@ -0,0 +1,16 @@
module.exports = function registerCanvasSizeRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
const pool = deps.pool;
const requirePermission = deps.requirePermission;
app.get('/canvas-sizes', requirePermission('canvas-sizes.read'), async function (req, res, next) {
try {
const data = await common.fetchCanvasSizesData(pool);
res.send(pages.renderCanvasSizesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};
+12
View File
@@ -0,0 +1,12 @@
const { renderView } = require('../../../view');
module.exports = function renderConnectedClientsPage(data, message, currentUser) {
return renderView('clients/list', {
title: 'Connected clients',
active: 'clients',
message: message,
currentUser: currentUser || null,
scripts: ['js/dashboard/dashboard-page.js'],
clients: data.clients || []
});
};
+16
View File
@@ -0,0 +1,16 @@
module.exports = function registerClientsRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const requirePermission = deps.requirePermission;
app.get('/clients', requirePermission('clients.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
res.send(pages.renderConnectedClientsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};
+16
View File
@@ -0,0 +1,16 @@
const { renderView } = require('../../../view');
module.exports = function renderDashboardPage(data, message, currentUser) {
return renderView('dashboard/index', {
title: 'Dashboard',
active: 'dashboard',
message: message,
currentUser: currentUser || null,
playlists: data.playlists || [],
screens: data.screens || [],
clients: data.clients || [],
slides: data.slides || [],
connectedClientsCount: Number(data.connectedClientsCount || 0),
scripts: ['js/dashboard/dashboard-page.js']
});
};
@@ -0,0 +1,15 @@
module.exports = function registerDashboardRoutes(app, deps) {
const pool = deps.pool;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const requirePermission = deps.requirePermission;
app.get('/dashboard', requirePermission('dashboard.read'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
res.send(pages.renderDashboardPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};
+14
View File
@@ -0,0 +1,14 @@
const { renderView } = require('../../../view');
module.exports = function renderPlaylistFormPage(message, currentUser) {
return renderView('playlists/add', {
title: 'Create playlist',
active: 'playlists',
playlist: {
fade_between_slides: false
},
message: message,
currentUser: currentUser || null
});
};
+115
View File
@@ -0,0 +1,115 @@
const { renderView } = require('../../../view');
function formatDateTime(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day} ${hours}:${minutes}`;
}
function formatTime(value) {
return value ? String(value).slice(0, 5) : '';
}
function formatDays(daysJson) {
const dayNames = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'];
let days = [];
if (Array.isArray(daysJson)) {
days = daysJson;
} else if (daysJson) {
try {
const parsed = JSON.parse(daysJson);
days = Array.isArray(parsed) ? parsed : [];
} catch (_error) {
days = [];
}
}
return days.map((day) => dayNames[Number(day)] || '').filter(Boolean).join(', ');
}
function scheduleSummary(item) {
const mode = String(item.schedule_mode || 'always');
if (mode === 'dates') {
const start = formatDateTime(item.schedule_start_datetime);
const end = formatDateTime(item.schedule_end_datetime);
return start && end ? `Dates: ${start} to ${end}` : 'Dates: not set';
}
if (mode === 'times') {
const days = formatDays(item.schedule_days_json);
const start = formatTime(item.schedule_start_time);
const end = formatTime(item.schedule_end_time);
return days && start && end ? `Times: ${days} ${start}-${end}` : 'Times: not set';
}
return 'Always visible';
}
function getCanvasSignature(width, height) {
const normalizedWidth = Number(width);
const normalizedHeight = Number(height);
if (!Number.isFinite(normalizedWidth) || !Number.isFinite(normalizedHeight)) {
return null;
}
return normalizedWidth + 'x' + normalizedHeight;
}
module.exports = function renderPlaylistEditPage(playlist, data, message, currentUser) {
const playlistSlides = (data.playlistSlides || [])
.filter((item) => item.playlist_id === playlist.id)
.sort((a, b) => {
if (a.position !== b.position) {
return a.position - b.position;
}
return a.id - b.id;
})
.map((item, index, items) => Object.assign({}, item, {
displayOrder: index + 1,
isFirst: index === 0,
isLast: index === items.length - 1,
canvasSignature: getCanvasSignature(item.canvas_width, item.canvas_height) || '',
scheduleSummary: scheduleSummary(item)
}));
const assignedSlideIds = new Set(playlistSlides.map((item) => item.slide_id));
const playlistCanvasSignatures = Array.from(new Set(playlistSlides.map((item) => getCanvasSignature(item.canvas_width, item.canvas_height)).filter(Boolean)));
const playlistCanvasSignature = playlistCanvasSignatures.length === 1 ? playlistCanvasSignatures[0] : null;
const playlistCanvasMismatch = playlistCanvasSignatures.length > 1;
const selectableSlides = (data.slides || []).filter((slide) => {
if (playlistCanvasMismatch) {
return false;
}
if (!playlistCanvasSignature) {
return true;
}
return getCanvasSignature(slide.canvas_width, slide.canvas_height) === playlistCanvasSignature;
}).map((slide) => Object.assign({}, slide, {
canvasSignature: getCanvasSignature(slide.canvas_width, slide.canvas_height) || '',
isAssigned: assignedSlideIds.has(slide.id)
}));
const hasAddableSlides = selectableSlides.some((slide) => !slide.isAssigned);
return renderView('playlists/edit', {
title: 'Edit playlist',
active: 'playlists',
message: message,
currentUser: currentUser || null,
playlist: playlist,
playlistFadeBetweenSlides: Boolean(playlist.fade_between_slides),
playlistSlides: playlistSlides,
slides: data.slides || [],
hasSlides: (data.slides || []).length > 0,
hasSelectableSlides: selectableSlides.length > 0,
hasAddableSlides: hasAddableSlides,
selectableSlides: selectableSlides,
playlistCanvasSignature: playlistCanvasSignature,
playlistCanvasMismatch: playlistCanvasMismatch,
scripts: ['js/vendor/sortable.min.js', 'js/playlists/playlist-schedule.js']
});
};
+21
View File
@@ -0,0 +1,21 @@
const { renderView } = require('../../../view');
module.exports = function renderPlaylistsPage(data, message, currentUser) {
const playlistSlides = data.playlistSlides || [];
const playlists = (data.playlists || []).map((playlist) => {
const slideCount = playlistSlides.filter((item) => item.playlist_id === playlist.id).length;
return Object.assign({}, playlist, {
slideCount: slideCount,
slideCountIsOne: slideCount === 1
});
});
return renderView('playlists/list', {
title: 'Playlists',
active: 'playlists',
message: message,
currentUser: currentUser || null,
playlists: playlists
});
};
@@ -0,0 +1,47 @@
module.exports = function registerPlaylistsRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const requirePermission = deps.requirePermission;
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
return requirePermission(permissionKey)(req, res, next);
};
}
app.get('/playlists', requireQueryPermission('playlists.read', 'playlists.update'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
if (req.query.edit) {
const playlist = await common.fetchPlaylistById(pool, Number(req.query.edit));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
return res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
}
res.send(pages.renderPlaylistsPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/playlists/new', requirePermission('playlists.create'), function (req, res) {
res.send(pages.renderPlaylistFormPage(req.query.message ? String(req.query.message) : '', req.currentUser));
});
app.get('/playlists/:id/edit', requirePermission('playlists.update'), async function (req, res, next) {
try {
const playlist = await common.fetchPlaylistById(pool, Number(req.params.id));
if (!playlist) {
return res.status(404).send('Playlist not found');
}
const data = await common.fetchAdminData(pool);
res.send(pages.renderPlaylistEditPage(playlist, data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};
@@ -0,0 +1,79 @@
const { renderFragment } = require('../../../view');
const DAY_OPTIONS = [
{ value: 0, label: 'Sun' },
{ value: 1, label: 'Mon' },
{ value: 2, label: 'Tue' },
{ value: 3, label: 'Wed' },
{ value: 4, label: 'Thu' },
{ value: 5, label: 'Fri' },
{ value: 6, label: 'Sat' }
];
const DEFAULT_START_TIME = '00:00';
const DEFAULT_END_TIME = '23:59';
function formatDateTimeInput(value) {
if (!value) {
return '';
}
const date = new Date(value);
if (Number.isNaN(date.getTime())) {
return '';
}
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
return `${year}-${month}-${day}T${hours}:${minutes}`;
}
function formatTimeInput(value) {
if (!value) {
return '';
}
return String(value).slice(0, 5);
}
module.exports = function renderPlaylistSlideConfigPage(playlist, playlistSlide, message, rowKey, currentUser) {
const slide = playlistSlide || {
id: 0,
schedule_mode: 'always',
schedule_days: [],
schedule_start_datetime: null,
schedule_end_datetime: null,
schedule_start_time: null,
schedule_end_time: null
};
const scheduleMode = String(slide.schedule_mode || 'always');
const selectedDays = new Set(Array.isArray(slide.schedule_days) ? slide.schedule_days.map(Number) : []);
const startTimeValue = formatTimeInput(slide.schedule_start_time) || (scheduleMode === 'times' ? DEFAULT_START_TIME : '');
const endTimeValue = formatTimeInput(slide.schedule_end_time) || (scheduleMode === 'times' ? DEFAULT_END_TIME : '');
return renderFragment('playlists/slide-config', {
title: 'Slide schedule',
framePopupCard: true,
hideFrameHeader: true,
hideThemeToggle: true,
frameBodyClass: 'admin-shell-body--frame-plain',
message: message,
currentUser: currentUser || null,
playlist: playlist,
playlistSlide: slide,
scheduleMode: scheduleMode,
isAlwaysMode: scheduleMode === 'always',
isDatesMode: scheduleMode === 'dates',
isTimesMode: scheduleMode === 'times',
rowKey: String(rowKey || ''),
startDateTimeValue: formatDateTimeInput(slide.schedule_start_datetime),
endDateTimeValue: formatDateTimeInput(slide.schedule_end_datetime),
startTimeValue: startTimeValue,
endTimeValue: endTimeValue,
dayOptions: DAY_OPTIONS.map((day) => ({
...day,
checked: selectedDays.has(day.value)
})),
scripts: ['js/playlists/playlist-schedule.js']
});
};
+12
View File
@@ -0,0 +1,12 @@
const { renderView } = require('../../../view');
module.exports = function renderScreenFormPage(data, message, currentUser) {
return renderView('screens/add', {
title: 'Add screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
playlists: data.playlists || []
});
};
+13
View File
@@ -0,0 +1,13 @@
const { renderView } = require('../../../view');
module.exports = function renderScreenEditPage(screen, data, message, currentUser) {
return renderView('screens/edit', {
title: 'Edit screen',
active: 'screens',
message: message,
currentUser: currentUser || null,
screen: screen,
playlists: data.playlists || []
});
};
+12
View File
@@ -0,0 +1,12 @@
const { renderView } = require('../../../view');
module.exports = function renderScreensPage(data, message, currentUser) {
return renderView('screens/list', {
title: 'Screens',
active: 'screens',
message: message,
currentUser: currentUser || null,
screens: data.screens || []
});
};
+45
View File
@@ -0,0 +1,45 @@
module.exports = function registerScreensRoutes(app, deps) {
const pool = deps.pool;
const common = deps.common;
const pages = deps.pages;
const buildDashboardState = deps.buildDashboardState;
const requirePermission = deps.requirePermission;
function requireQueryPermission(readPermissionKey, editPermissionKey) {
return function (req, res, next) {
const permissionKey = req.query && req.query.edit ? editPermissionKey : readPermissionKey;
return requirePermission(permissionKey)(req, res, next);
};
}
app.get('/screens', requireQueryPermission('screens.read', 'screens.update'), async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
if (req.query.edit) {
const screen = await common.fetchScreenById(pool, Number(req.query.edit));
if (!screen) {
return res.status(404).send('Screen not found');
}
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
}
res.send(pages.renderScreensPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
app.get('/screens/:id/edit', requirePermission('screens.update'), async function (req, res, next) {
try {
const screen = await common.fetchScreenById(pool, Number(req.params.id));
if (!screen) {
return res.status(404).send('Screen not found');
}
const editData = await common.fetchScreenEditData(pool);
return res.send(pages.renderScreenEditPage(screen, editData, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};
+33
View File
@@ -0,0 +1,33 @@
const { renderView } = require('../../../view');
module.exports = function renderSlideFormPage(data, mode, slide, message, currentUser) {
const templateRegions = data.templateRegions || [];
const rssFeeds = data.rssFeeds || [];
const apiSources = data.apiSources || [];
const templates = (data.templates || []).map((template) => ({
...template,
canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height,
regions: templateRegions.filter((region) => Number(region.template_id) === Number(template.id))
}));
return renderView('slides/form', {
title: mode === 'edit' ? 'Edit slide' : 'Create slide',
active: 'slides',
message: message,
isEdit: mode === 'edit',
saveLabel: 'Save',
action: mode === 'edit' ? `/slides/${slide.id}` : '/slides',
slide: slide || { title: '', template_id: null, content: {} },
templates: templates,
stylesheets: ['js/vendor/ckeditor5/ckeditor5.css'],
slideEditorData: {
templates: templates,
rssFeeds: rssFeeds,
apiSources: apiSources,
existingTemplateId: slide && slide.template_id ? slide.template_id : null,
existingContent: slide && slide.content ? slide.content : {}
},
currentUser: currentUser || null
});
};
+12
View File
@@ -0,0 +1,12 @@
const { renderView } = require('../../../view');
module.exports = function renderSlidesPage(data, message, currentUser) {
return renderView('slides/list', {
title: 'Slides',
active: 'slides',
message: message,
currentUser: currentUser || null,
slides: data.slides || []
});
};
+16
View File
@@ -0,0 +1,16 @@
module.exports = function registerSlidesRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
const pool = deps.pool;
const requirePermission = deps.requirePermission;
app.get('/slides', requirePermission('slides.read'), async function (req, res, next) {
try {
const data = await common.fetchAdminData(pool);
res.send(pages.renderSlidesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};
+55
View File
@@ -0,0 +1,55 @@
const { renderView } = require('../../../view');
function buildDefaultTemplate() {
return {
id: null,
name: '',
canvas_size_id: null,
canvas_size_width: 1920,
canvas_size_height: 1080,
background_color: '#111111',
background_image_path: '',
regions: []
};
}
function resolveTemplateCanvasSize(template, canvasSizes) {
const currentWidth = Math.max(1, Number(template.canvas_size_width || 1920));
const currentHeight = Math.max(1, Number(template.canvas_size_height || 1080));
const sizeId = template.canvas_size_id ? Number(template.canvas_size_id) : null;
const matched = (canvasSizes || []).find((size) => Number(size.id) === sizeId)
|| (canvasSizes || []).find((size) => Number(size.width) === currentWidth && Number(size.height) === currentHeight);
if (matched) {
return Object.assign({}, template, {
canvas_size_id: matched.id,
canvas_size_label: matched.name,
canvas_size_width: matched.width,
canvas_size_height: matched.height,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
return Object.assign({}, template, {
canvas_size_id: '',
canvas_size_label: 'Current size (' + currentWidth + 'x' + currentHeight + ')',
canvas_size_width: currentWidth,
canvas_size_height: currentHeight,
background_color: String(template.background_color || '#111111').trim() || '#111111'
});
}
module.exports = function renderTemplateFormPage(template, mode, message, canvasSizes, currentUser) {
const isEdit = mode === 'edit';
const current = resolveTemplateCanvasSize(template || buildDefaultTemplate(), canvasSizes || []);
return renderView(isEdit ? 'templates/edit' : 'templates/add', {
title: isEdit ? 'Edit template' : 'Create Template',
active: 'templates',
message: message,
currentUser: currentUser || null,
template: current,
canvasSizes: canvasSizes || [],
scripts: ['js/templates/template-designer-utils.js', 'js/templates/template-designer.js']
});
};
+5
View File
@@ -0,0 +1,5 @@
const renderTemplateFormPage = require('./add');
module.exports = function renderTemplateEditPage(template, data, message, currentUser) {
return renderTemplateFormPage(template, 'edit', message, data && data.canvasSizes ? data.canvasSizes : [], currentUser);
};
+19
View File
@@ -0,0 +1,19 @@
const { renderView } = require('../../../view');
module.exports = function renderTemplatesPage(data, message, currentUser) {
const templates = (data.templates || []).map((template) => ({
id: template.id,
name: template.name,
canvas_size_width: template.canvas_size_width,
canvas_size_height: template.canvas_size_height,
regionCount: (data.templateRegions || []).filter((region) => region.template_id === template.id).length
}));
return renderView('templates/list', {
title: 'Slide templates',
active: 'templates',
message: message,
currentUser: currentUser || null,
templates: templates
});
};
@@ -0,0 +1,16 @@
module.exports = function registerTemplatesRoutes(app, deps) {
const pages = deps.pages;
const common = deps.common;
const pool = deps.pool;
const requirePermission = deps.requirePermission;
app.get('/templates', requirePermission('templates.read'), async function (req, res, next) {
try {
const data = await common.fetchTemplatesData(pool);
res.send(pages.renderTemplatesPage(data, req.query.message ? String(req.query.message) : '', req.currentUser));
} catch (error) {
next(error);
}
});
};