Refactor web server and release workflow

This commit is contained in:
2026-07-14 18:35:24 +01:00
parent 9070ded66d
commit 4e0e87c86c
76 changed files with 616 additions and 84 deletions
+4 -1
View File
@@ -106,9 +106,12 @@ async function buildTemplatePayload(pool, req, existingTemplate) {
let regions = extractTemplateRegions(req.body);
const filesByField = getFilesByField(req.files || []);
const backgroundImage = filesByField.background_image;
const removeBackgroundImage = Boolean(req.body.remove_background_image);
const backgroundImagePath = backgroundImage
? `/uploads/${backgroundImage.filename}`
: String(req.body.existing_background_image_path || (existingTemplate && existingTemplate.background_image_path) || '').trim() || null;
: 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.');
+48 -1
View File
@@ -6,6 +6,7 @@ const path = require('path');
const { WebSocketServer, WebSocket } = require('ws');
const common = require('./common');
// Playlist assembly and revision helpers.
async function buildScreenPlaylist(pool, slug) {
const [screenRows] = await pool.query('SELECT id, name, slug, playlist_id, created_at, modified_at, created_by, modified_by FROM screens WHERE slug = ?', [slug]);
if (!screenRows.length) {
@@ -147,12 +148,13 @@ function getPlaylistRevision(screen, playlist, slideRows, templateRows, regionRo
}
// Player runtime, upload API, and websocket wiring.
async function start() {
const app = express();
const pool = common.createPool();
const PORT = Number(process.env.PLAYER_PORT || 3001);
const ASSET_DIR = path.join(__dirname, 'player', 'public');
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const connectionsBySlug = new Map();
const dashboardListenersBySlug = new Map();
const server = http.createServer(app);
@@ -160,9 +162,52 @@ async function start() {
app.use(express.json());
// Static assets and mirrored uploads are served from the player container.
app.use('/assets', express.static(ASSET_DIR));
app.use('/uploads', express.static(UPLOAD_DIR));
app.get('/api/uploads/config', function (_req, res) {
res.json({
uploadDir: UPLOAD_DIR
});
});
app.put('/api/uploads/:filename', express.raw({ type: '*/*', limit: '100mb' }), async function (req, res, next) {
try {
const filename = path.basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = path.join(UPLOAD_DIR, filename);
const body = Buffer.isBuffer(req.body) ? req.body : Buffer.from(req.body || '');
await fs.promises.mkdir(UPLOAD_DIR, { recursive: true });
await fs.promises.writeFile(filePath, body);
res.json({ ok: true, filename: filename });
} catch (error) {
next(error);
}
});
app.delete('/api/uploads/:filename', async function (req, res, next) {
try {
const filename = path.basename(String(req.params.filename || '').trim());
if (!filename) {
return res.status(400).json({ error: 'Filename is required' });
}
const filePath = path.join(UPLOAD_DIR, filename);
try {
await fs.promises.unlink(filePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
throw error;
}
}
res.json({ ok: true, filename: filename });
} catch (error) {
next(error);
}
});
function getConnectionBucket(slug) {
const key = String(slug || '').trim();
if (!key) {
@@ -337,6 +382,7 @@ async function start() {
res.send('Pulse Signage player service');
});
// Screen playback endpoints render the active playlist for a slug.
app.get('/screen/:slug', function (req, res) {
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
res.set('Pragma', 'no-cache');
@@ -439,6 +485,7 @@ async function start() {
await common.ensureSchema(pool);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
// Websocket upgrades split dashboard snapshots from player client sessions.
server.on('upgrade', function (request, socket, head) {
let pathname = '';
try {
+1 -1
View File
@@ -1060,7 +1060,7 @@
function renderSlideShell(slide, canvasClass, canvasWidth, canvasHeight, innerHtml) {
const body = slide.body ? '<div class="body">' + escapeHtml(slide.body) + '</div>' : '';
const className = canvasClass ? 'slide-canvas ' + canvasClass : 'slide-canvas';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';"><div class="overlay"><div>' + escapeHtml(slide.title) + '</div></div>' + innerHtml + body + '</div></div>';
return '<div class="slide"><div class="' + className + '" style="width:' + canvasWidth + ';height:' + canvasHeight + ';">' + innerHtml + body + '</div></div>';
}
// Build the cache key for rendered slide markup.
-12
View File
@@ -86,18 +86,6 @@ body.screen-blackout #app {
border: 0;
}
.overlay {
position: absolute;
left: 0;
right: 0;
top: 0;
padding: 14px 18px;
background: rgba(0, 0, 0, 0.35);
font-size: 14px;
display: flex;
justify-content: space-between;
}
.body {
position: absolute;
left: 5%;
+476 -13
View File
@@ -7,7 +7,7 @@ const crypto = require('crypto');
const { WebSocketServer, WebSocket } = require('ws');
const common = require('./common');
const { verifyPassword, createSessionToken, hashSessionToken, hashPassword } = require('./auth');
const pages = require('./webui/routes');
const pages = require('./web/routes');
const PLAYER_INTERNAL_BASE_URL = (process.env.PLAYER_INTERNAL_BASE_URL || process.env.PLAYER_BASE_URL || 'http://player:3001').replace(/\/$/, '');
const PLAYER_PUBLIC_BASE_URL = (process.env.PLAYER_PUBLIC_BASE_URL || process.env.PLAYER_BASE_URL || 'http://localhost:3001').replace(/\/$/, '');
const PLAYER_WS_BASE_URL = PLAYER_INTERNAL_BASE_URL.replace(/^http/, 'ws');
@@ -17,6 +17,18 @@ const SESSION_MAX_AGE_MS = (Number.isFinite(SESSION_MAX_AGE_DAYS) && SESSION_MAX
const playerSnapshotCache = new Map();
const playerSnapshotSockets = new Map();
let playerUploadSyncMode = null;
let playerUploadSyncModePromise = null;
const pendingPlayerUploadSyncs = new Map();
let pendingPlayerUploadSyncFlushTimer = null;
let pendingPlayerUploadSyncFlushInFlight = null;
const pendingPlaylistUploadSyncs = new Map();
let pendingPlaylistUploadSyncFlushTimer = null;
let pendingPlaylistUploadSyncFlushInFlight = null;
function normalizeUploadRoot(uploadDir) {
return path.resolve(String(uploadDir || '').trim());
}
function readArrayField(body, keys) {
const searchKeys = Array.isArray(keys) ? keys : [keys];
@@ -60,6 +72,7 @@ function normalizeScheduleMode(value) {
}
// Session and auth helpers.
function getAuditUserId(req) {
return req && req.currentUser ? Number(req.currentUser.id) : null;
}
@@ -91,6 +104,7 @@ async function fetchPlaylistCanvasSignature(pool, playlistId) {
return signatures.length === 1 ? signatures[0] : 'mismatch';
}
// Player snapshot and dashboard helpers.
function getPlayerSnapshotSocketUrl(slug) {
const url = new URL(PLAYER_WS_BASE_URL);
url.pathname = `/ws/screens/${encodeURIComponent(slug)}/events`;
@@ -178,6 +192,7 @@ function buildClientRows(screens, connectionsBySlug) {
});
}
// Player command helpers.
async function forwardPlayerCommand(slug, commandOrPayload, connectionId) {
const payload = typeof commandOrPayload === 'object' && commandOrPayload !== null
? Object.assign({}, commandOrPayload)
@@ -224,6 +239,7 @@ async function notifyPlayerScreens(slugs, commandOrPayload) {
}).length;
}
// Screen lookup helpers used by the admin routes.
async function fetchAllScreenSlugs(connection) {
const [rows] = await connection.query('SELECT slug FROM screens WHERE slug IS NOT NULL');
return rows.map(function (row) {
@@ -255,6 +271,33 @@ async function fetchScreensBySlideId(connection, slideId) {
});
}
async function fetchScreensByTemplateId(connection, templateId) {
const [rows] = await connection.query(
`SELECT DISTINCT s.slug
FROM screens s
JOIN playlist_slides ps ON ps.playlist_id = s.playlist_id
JOIN slides sl ON sl.id = ps.slide_id
WHERE sl.template_id = ?
AND s.slug IS NOT NULL`,
[templateId]
);
return rows.map(function (row) {
return row.slug;
});
}
async function fetchSlideIdsByTemplateId(connection, templateId) {
const [rows] = await connection.query(
'SELECT id FROM slides WHERE template_id = ?',
[templateId]
);
return rows.map(function (row) {
return Number(row.id);
}).filter(function (value) {
return Number.isFinite(value);
});
}
function formatDashboardDate(value) {
if (!value) {
return '';
@@ -325,6 +368,7 @@ async function fetchOrderedPlaylistSlides(connection, playlistId) {
return rows;
}
// Upload synchronization and retry helpers.
function createUploadMiddleware(uploadDir) {
const storage = multer.diskStorage({
destination: function (_req, _file, cb) {
@@ -438,9 +482,385 @@ async function removeUnusedUploadFiles(pool, uploadDir, uploadPaths) {
console.warn('Unable to remove unused upload file:', filePath, error);
}
}
queuePlayerUploadSync({
type: 'delete',
uploadPath: uploadPath,
uploadDir: uploadDir
});
}
}
async function getPlayerUploadSyncMode(localUploadDir) {
if (playerUploadSyncMode) {
return playerUploadSyncMode;
}
if (playerUploadSyncModePromise) {
return playerUploadSyncModePromise;
}
playerUploadSyncModePromise = (async function () {
try {
const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/config`, {
headers: {
Accept: 'application/json'
}
});
if (!response.ok) {
return null;
}
const data = await response.json();
const playerUploadDir = data && data.uploadDir ? normalizeUploadRoot(data.uploadDir) : null;
if (!playerUploadDir) {
return null;
}
return playerUploadDir === normalizeUploadRoot(localUploadDir) ? 'shared' : 'different';
} catch (_error) {
return null;
}
})().then(function (mode) {
if (mode) {
playerUploadSyncMode = mode;
}
playerUploadSyncModePromise = null;
return mode;
}, function () {
playerUploadSyncModePromise = null;
return null;
});
return playerUploadSyncModePromise;
}
async function shouldMirrorUploads(localUploadDir) {
return Boolean(localUploadDir);
}
function queuePlayerUploadSync(operation) {
if (!operation || !operation.uploadPath) {
return;
}
pendingPlayerUploadSyncs.set(normalizeUploadReference(operation.uploadPath), {
type: operation.type === 'delete' ? 'delete' : 'put',
uploadPath: normalizeUploadReference(operation.uploadPath),
uploadDir: operation.uploadDir || null
});
schedulePendingPlayerUploadSyncFlush();
}
function schedulePendingPlayerUploadSyncFlush() {
if (pendingPlayerUploadSyncFlushTimer) {
return;
}
pendingPlayerUploadSyncFlushTimer = setTimeout(function () {
pendingPlayerUploadSyncFlushTimer = null;
flushPendingPlayerUploadSyncs().catch(function (error) {
console.warn('Unable to flush pending upload syncs:', error);
});
}, 5000);
}
async function pushUploadFileToPlayer(uploadPath, localUploadDir) {
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
return false;
}
const filename = path.basename(uploadPath);
const sourcePath = path.join(localUploadDir, filename);
let fileBuffer = null;
try {
fileBuffer = await fs.promises.readFile(sourcePath);
} catch (error) {
if (!error || error.code !== 'ENOENT') {
console.warn('Unable to read upload for player sync:', sourcePath, error);
}
return;
}
try {
const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/${encodeURIComponent(filename)}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/octet-stream'
},
body: fileBuffer
});
if (!response.ok) {
console.warn('Unable to sync upload to player:', filename, response.status, response.statusText);
return false;
}
return true;
} catch (error) {
console.warn('Unable to sync upload to player:', filename, error);
return false;
}
}
async function removeUploadFileFromPlayer(uploadPath, localUploadDir) {
if (!uploadPath || !(await shouldMirrorUploads(localUploadDir))) {
return false;
}
const filename = path.basename(uploadPath);
try {
const response = await fetch(`${PLAYER_INTERNAL_BASE_URL}/api/uploads/${encodeURIComponent(filename)}`, {
method: 'DELETE',
headers: {
Accept: 'application/json'
}
});
if (!response.ok && response.status !== 404) {
console.warn('Unable to remove upload from player:', filename, response.status, response.statusText);
return false;
}
return true;
} catch (error) {
console.warn('Unable to remove upload from player:', filename, error);
return false;
}
}
async function syncUploadRefsToPlayer(uploadRefs, localUploadDir) {
if (!(await shouldMirrorUploads(localUploadDir))) {
return;
}
const uniqueRefs = Array.from(new Set((uploadRefs || []).map(normalizeUploadReference).filter(Boolean)));
for (let i = 0; i < uniqueRefs.length; i += 1) {
const success = await pushUploadFileToPlayer(uniqueRefs[i], localUploadDir);
if (!success) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uniqueRefs[i],
uploadDir: localUploadDir
});
}
}
}
async function syncExistingUploadsToPlayer(pool, localUploadDir) {
if (!(await shouldMirrorUploads(localUploadDir))) {
return;
}
const data = await common.fetchAdminData(pool);
const uploadRefs = new Set();
(data.slides || []).forEach(function (slide) {
collectUploadReferencesFromSlide(slide).forEach(function (reference) {
uploadRefs.add(reference);
});
});
(data.templates || []).forEach(function (template) {
collectUploadReferencesFromTemplate(template).forEach(function (reference) {
uploadRefs.add(reference);
});
});
Array.from(uploadRefs).forEach(function (uploadPath) {
queuePlayerUploadSync({
type: 'put',
uploadPath: uploadPath,
uploadDir: localUploadDir
});
});
await flushPendingPlayerUploadSyncs();
}
function getVisibleCurrentSlideIds() {
const visibleSlideIds = new Set();
playerSnapshotCache.forEach(function (snapshot) {
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
connections.forEach(function (connection) {
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
? connection.currentSlide
: null;
const slideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
? String(currentSlide.id).trim()
: '';
if (slideId) {
visibleSlideIds.add(slideId);
}
});
});
return visibleSlideIds;
}
function isScreenRefreshBlocked(screenSlug, blockedSlideIds) {
const slideIds = Array.isArray(blockedSlideIds)
? blockedSlideIds.map(function (value) {
return String(value || '').trim();
}).filter(Boolean)
: [];
if (!slideIds.length) {
return false;
}
const snapshot = playerSnapshotCache.get(String(screenSlug || '').trim());
const connections = snapshot && Array.isArray(snapshot.connections) ? snapshot.connections : [];
return connections.some(function (connection) {
const currentSlide = connection && connection.currentSlide && typeof connection.currentSlide === 'object'
? connection.currentSlide
: null;
const currentSlideId = currentSlide && currentSlide.id !== undefined && currentSlide.id !== null
? String(currentSlide.id).trim()
: '';
return currentSlideId && slideIds.includes(currentSlideId);
});
}
function splitRefreshScreenSlugsByVisibility(screenSlugs, blockedSlideIds) {
const ready = [];
const blocked = [];
Array.from(new Set(Array.isArray(screenSlugs) ? screenSlugs : [])).forEach(function (screenSlug) {
const normalizedScreenSlug = String(screenSlug || '').trim();
if (!normalizedScreenSlug) {
return;
}
if (isScreenRefreshBlocked(normalizedScreenSlug, blockedSlideIds)) {
blocked.push(normalizedScreenSlug);
} else {
ready.push(normalizedScreenSlug);
}
});
return { ready: ready, blocked: blocked };
}
function normalizePlaylistUploadSyncOperation(options) {
return {
key: String(options && options.key ? options.key : '').trim(),
pool: options && options.pool ? options.pool : null,
localUploadDir: options && options.localUploadDir ? options.localUploadDir : null,
previousUploadRefs: Array.from(new Set(options && options.previousUploadRefs ? options.previousUploadRefs : [])),
nextUploadRefs: Array.from(new Set(options && options.nextUploadRefs ? options.nextUploadRefs : [])),
blockedSlideIds: Array.from(new Set(options && options.blockedSlideIds ? options.blockedSlideIds : [])).map(function (value) {
return String(value || '').trim();
}).filter(Boolean),
refreshScreenSlugs: Array.from(new Set(options && options.refreshScreenSlugs ? options.refreshScreenSlugs : [])).map(function (value) {
return String(value || '').trim();
}).filter(Boolean)
};
}
function queuePlaylistUploadSync(operation) {
if (!operation || !operation.key) {
return;
}
pendingPlaylistUploadSyncs.set(operation.key, normalizePlaylistUploadSyncOperation(operation));
schedulePendingPlaylistUploadSyncFlush();
}
function schedulePendingPlaylistUploadSyncFlush() {
if (pendingPlaylistUploadSyncFlushTimer) {
return;
}
pendingPlaylistUploadSyncFlushTimer = setTimeout(function () {
pendingPlaylistUploadSyncFlushTimer = null;
flushPendingPlaylistUploadSyncs().catch(function (error) {
console.warn('Unable to flush pending playlist upload syncs:', error);
});
}, 5000);
}
async function syncPlaylistUploadsOnChange(options) {
const operation = normalizePlaylistUploadSyncOperation(options);
if (!operation.key) {
return;
}
if (operation.previousUploadRefs.length) {
const nextUploadRefSet = new Set(operation.nextUploadRefs);
await removeUnusedUploadFiles(operation.pool, operation.localUploadDir, operation.previousUploadRefs.filter(function (reference) {
return !nextUploadRefSet.has(reference);
}));
}
if (operation.nextUploadRefs.length) {
await syncUploadRefsToPlayer(operation.nextUploadRefs, operation.localUploadDir);
}
if (operation.refreshScreenSlugs.length) {
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds);
if (refreshTargets.ready.length) {
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
}
refreshTargets.blocked.forEach(function (screenSlug) {
queuePlaylistUploadSync({
key: operation.key + ':refresh:' + screenSlug,
blockedSlideIds: operation.blockedSlideIds,
refreshScreenSlugs: [screenSlug]
});
});
}
}
async function flushPendingPlaylistUploadSyncs() {
if (pendingPlaylistUploadSyncFlushInFlight) {
return pendingPlaylistUploadSyncFlushInFlight;
}
if (!pendingPlaylistUploadSyncs.size) {
return null;
}
pendingPlaylistUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlaylistUploadSyncs.values());
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
const refreshTargets = splitRefreshScreenSlugsByVisibility(operation.refreshScreenSlugs, operation.blockedSlideIds);
if (!refreshTargets.ready.length) {
continue;
}
if (refreshTargets.ready.length) {
await notifyPlayerScreens(refreshTargets.ready, 'refresh');
}
pendingPlaylistUploadSyncs.delete(operation.key);
}
})().finally(function () {
pendingPlaylistUploadSyncFlushInFlight = null;
if (pendingPlaylistUploadSyncs.size) {
schedulePendingPlaylistUploadSyncFlush();
}
});
return pendingPlaylistUploadSyncFlushInFlight;
}
async function flushPendingPlayerUploadSyncs() {
if (pendingPlayerUploadSyncFlushInFlight) {
return pendingPlayerUploadSyncFlushInFlight;
}
if (!pendingPlayerUploadSyncs.size) {
return null;
}
pendingPlayerUploadSyncFlushInFlight = (async function () {
const pendingEntries = Array.from(pendingPlayerUploadSyncs.values());
for (let i = 0; i < pendingEntries.length; i += 1) {
const operation = pendingEntries[i];
let success = false;
if (operation.type === 'delete') {
success = await removeUploadFileFromPlayer(operation.uploadPath, operation.uploadDir);
} else {
success = await pushUploadFileToPlayer(operation.uploadPath, operation.uploadDir);
}
if (success) {
pendingPlayerUploadSyncs.delete(operation.uploadPath);
}
}
})().finally(function () {
pendingPlayerUploadSyncFlushInFlight = null;
if (pendingPlayerUploadSyncs.size) {
schedulePendingPlayerUploadSyncFlush();
}
});
return pendingPlayerUploadSyncFlushInFlight;
}
function parseCookies(cookieHeader) {
return String(cookieHeader || '').split(/;\s*/).reduce(function (cookies, pair) {
if (!pair) {
@@ -521,13 +941,14 @@ function requireAuth(req, res, next) {
res.redirect('/login?message=' + encodeURIComponent('Please sign in to continue.'));
}
// Admin app routes and websocket wiring.
async function start() {
const app = express();
const server = http.createServer(app);
const pool = common.createPool();
const PORT = Number(process.env.WEB_PORT || 3000);
const UPLOAD_DIR = process.env.UPLOAD_DIR || path.join(__dirname, '..', 'uploads');
const ASSET_DIR = path.join(__dirname, 'webui', 'public');
const UPLOAD_DIR = path.join(__dirname, '..', 'uploads');
const ASSET_DIR = path.join(__dirname, 'web', 'public');
const dashboardWs = new WebSocketServer({ noServer: true });
const dashboardClients = new Set();
const dashboardRefreshIntervalMs = Number(process.env.DASHBOARD_REFRESH_INTERVAL_MS || 2000);
@@ -599,6 +1020,7 @@ async function start() {
app.use('/admin', requireAuth);
// Dashboard and account pages live in the authenticated admin area.
app.get('/admin', async function (req, res, next) {
try {
const data = await buildDashboardState(pool);
@@ -1547,6 +1969,12 @@ async function start() {
'INSERT INTO slides (title, body, template_id, content_json, media_path, media_type, created_by, modified_by) VALUES (?, ?, ?, ?, ?, ?, ?, ?)',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, actorId]
);
await syncPlaylistUploadsOnChange({
key: 'slide:create:' + result.insertId,
pool: pool,
localUploadDir: UPLOAD_DIR,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
res.redirect('/admin/slides/' + result.insertId + '/edit?message=' + encodeURIComponent('Slide created.'));
} catch (error) {
next(error);
@@ -1568,10 +1996,15 @@ async function start() {
'UPDATE slides SET title = ?, body = ?, template_id = ?, content_json = ?, media_path = ?, media_type = ?, modified_by = ? WHERE id = ?',
[payload.title, payload.body, payload.templateId, payload.contentJson, payload.mediaPath, payload.mediaType, actorId, slide.id]
);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) {
return !nextUploadRefs.has(reference);
}));
await notifyPlayerScreens(affectedScreens, 'refresh');
await syncPlaylistUploadsOnChange({
key: 'slide:update:' + slide.id,
pool: pool,
localUploadDir: UPLOAD_DIR,
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs,
blockedSlideIds: [slide.id],
refreshScreenSlugs: affectedScreens
});
await broadcastDashboardState();
res.redirect('/admin/slides/' + slide.id + '/edit?message=' + encodeURIComponent('Slide updated.'));
} catch (error) {
@@ -1588,8 +2021,14 @@ async function start() {
const affectedScreens = await fetchScreensBySlideId(pool, slide.id);
const uploadRefs = collectUploadReferencesFromSlide(slide);
await pool.query('DELETE FROM slides WHERE id = ?', [slide.id]);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs));
await notifyPlayerScreens(affectedScreens, 'refresh');
await syncPlaylistUploadsOnChange({
key: 'slide:delete:' + slide.id,
pool: pool,
localUploadDir: UPLOAD_DIR,
previousUploadRefs: uploadRefs,
blockedSlideIds: [slide.id],
refreshScreenSlugs: affectedScreens
});
await broadcastDashboardState();
res.redirect('/admin/slides?message=' + encodeURIComponent('Slide deleted.'));
} catch (error) {
@@ -1630,6 +2069,12 @@ async function start() {
[result.insertId, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
key: 'template:create:' + result.insertId,
pool: pool,
localUploadDir: UPLOAD_DIR,
nextUploadRefs: collectUploadReferencesFromPayload(payload)
});
res.redirect('/admin/templates/' + result.insertId + '/edit?message=' + encodeURIComponent('Template created.'));
} catch (error) {
next(error);
@@ -1658,14 +2103,12 @@ async function start() {
const existingUploadRefs = collectUploadReferencesFromTemplate(template);
const payload = await common.buildTemplatePayload(pool, req, template);
const nextUploadRefs = collectUploadReferencesFromPayload(payload);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
const actorId = getAuditUserId(req);
await pool.query(
'UPDATE slide_templates SET name = ?, canvas_size_id = ?, background_image_path = ?, modified_by = ? WHERE id = ?',
[payload.name, payload.canvasSizeId, payload.backgroundImagePath, actorId, template.id]
);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(existingUploadRefs).filter(function (reference) {
return !nextUploadRefs.has(reference);
}));
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
for (let i = 0; i < payload.regions.length; i += 1) {
const region = payload.regions[i];
@@ -1674,6 +2117,14 @@ async function start() {
[template.id, region.region_key, region.region_type, region.label, region.font_family, region.x, region.y, region.width, region.height, region.z_index, actorId, actorId]
);
}
await syncPlaylistUploadsOnChange({
key: 'template:update:' + template.id,
pool: pool,
localUploadDir: UPLOAD_DIR,
previousUploadRefs: existingUploadRefs,
nextUploadRefs: nextUploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
res.redirect('/admin/templates/' + template.id + '/edit?message=' + encodeURIComponent('Template updated.'));
} catch (error) {
next(error);
@@ -1687,10 +2138,17 @@ async function start() {
return res.status(404).send('Template not found');
}
const uploadRefs = collectUploadReferencesFromTemplate(template);
const affectedScreens = await fetchScreensByTemplateId(pool, template.id);
await pool.query('UPDATE slides SET template_id = NULL, modified_by = ? WHERE template_id = ?', [getAuditUserId(req), template.id]);
await pool.query('DELETE FROM slide_template_regions WHERE template_id = ?', [template.id]);
await pool.query('DELETE FROM slide_templates WHERE id = ?', [template.id]);
await removeUnusedUploadFiles(pool, UPLOAD_DIR, Array.from(uploadRefs));
await syncPlaylistUploadsOnChange({
key: 'template:delete:' + template.id,
pool: pool,
localUploadDir: UPLOAD_DIR,
previousUploadRefs: uploadRefs
});
await notifyPlayerScreens(affectedScreens, 'refresh');
res.redirect('/admin/templates?message=' + encodeURIComponent('Template deleted.'));
} catch (error) {
next(error);
@@ -1766,7 +2224,12 @@ async function start() {
res.status(error.statusCode || 500).send(error.statusCode ? error.message : 'Internal server error');
});
// Ensure schema and mirror uploads before the web service starts handling traffic.
await common.ensureSchema(pool);
fs.mkdirSync(UPLOAD_DIR, { recursive: true });
syncExistingUploadsToPlayer(pool, UPLOAD_DIR).catch(function (error) {
console.warn('Unable to sync existing uploads to player:', error);
});
server.on('upgrade', async function (request, socket, head) {
let pathname = '';
try {
@@ -2286,14 +2286,34 @@ table td .actions form {
.template-meta-row {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr) minmax(0, 1fr);
gap: 8px;
gap: 12px;
align-items: stretch;
}
.template-meta-row > label {
flex: 1 1 0;
display: flex;
flex-direction: column;
gap: 8px;
margin-top: 0;
}
.template-background-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
}
.template-background-row input[type="file"] {
flex: 1 1 auto;
min-width: 0;
}
.template-background-row button {
flex: 0 0 auto;
white-space: nowrap;
}
.template-designer-sidebar {
display: grid;
gap: 8px;
@@ -2380,7 +2400,7 @@ table td .actions form {
.designer-rect-label {
position: absolute;
left: 0;
left: 12px;
top: 0;
padding: 2px 6px;
background: rgba(15, 23, 42, 0.8);
@@ -2390,34 +2410,34 @@ table td .actions form {
.designer-rect .resize-handle {
position: absolute;
width: 12px;
height: 12px;
width: 24px;
height: 24px;
background: var(--surface);
border: 1px solid var(--border-strong);
box-sizing: border-box;
}
.designer-rect .resize-handle.nw {
left: -7px;
top: -7px;
left: -13px;
top: -13px;
cursor: nwse-resize;
}
.designer-rect .resize-handle.ne {
right: -7px;
top: -7px;
right: -13px;
top: -13px;
cursor: nesw-resize;
}
.designer-rect .resize-handle.sw {
left: -7px;
bottom: -7px;
left: -13px;
bottom: -13px;
cursor: nesw-resize;
}
.designer-rect .resize-handle.se {
right: -7px;
bottom: -7px;
right: -13px;
bottom: -13px;
cursor: nwse-resize;
}
@@ -322,7 +322,7 @@
updateDashboardQuickActions(state);
}
window.webuiHandleDashboardState = handleDashboardState;
window.webHandleDashboardState = handleDashboardState;
function initConfirmForms() {
document.addEventListener('submit', function (event) {
@@ -64,8 +64,8 @@
socket.onmessage = function (event) {
try {
var payload = JSON.parse(String(event.data || '{}'));
if (payload && payload.type === 'dashboard-state' && typeof window.webuiHandleDashboardState === 'function') {
window.webuiHandleDashboardState(payload.state);
if (payload && payload.type === 'dashboard-state' && typeof window.webHandleDashboardState === 'function') {
window.webHandleDashboardState(payload.state);
updateSidebarStatus(Boolean(payload.state && payload.state.playerServiceConnected), 'Live player feed');
}
} catch (_error) {
@@ -26,6 +26,8 @@
var backgroundInput = document.getElementById('background-image');
var backgroundPreview = document.getElementById('background-preview');
var backgroundEmpty = document.getElementById('background-empty');
var removeBackgroundButton = document.getElementById('remove-background-image');
var removeBackgroundFlag = document.getElementById('remove-background-image-flag');
var addTextRegionButton = document.getElementById('add-text-region');
var addImageRegionButton = document.getElementById('add-image-region');
var regionsJsonInput = document.getElementById('regions-json');
@@ -168,6 +170,9 @@
if (!file) {
return;
}
if (removeBackgroundFlag) {
removeBackgroundFlag.checked = false;
}
var reader = new FileReader();
reader.onload = function () {
backgroundPreview.src = reader.result;
@@ -431,6 +436,15 @@
updateBackgroundPreview(file);
}
});
if (removeBackgroundButton && removeBackgroundFlag) {
removeBackgroundButton.addEventListener('click', function () {
removeBackgroundFlag.checked = true;
backgroundInput.value = '';
backgroundPreview.removeAttribute('src');
backgroundPreview.style.display = 'none';
backgroundEmpty.style.display = 'block';
});
}
canvasSizeSelect.addEventListener('change', function () { syncCanvasSizeSelection(); render(); });
canvasWidthInput.addEventListener('input', render);
canvasHeightInput.addEventListener('input', render);
@@ -1,5 +1,5 @@
(function () {
var storageKey = 'webui-theme';
var storageKey = 'web-theme';
var theme = 'light';
try {
@@ -1,5 +1,5 @@
(function () {
var THEME_STORAGE_KEY = 'webui-theme';
var THEME_STORAGE_KEY = 'web-theme';
function getPreferredTheme() {
if (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches) {
+16
View File
@@ -0,0 +1,16 @@
<div class="page-header">
<div>
<h2>Create playlist</h2>
<p>Name the playlist and start adding slides.</p>
</div>
<a class="button-link" href="/admin/playlists">Back to playlists</a>
</div>
<div class="card">
<form method="post" action="/admin/playlists" data-async-save data-async-save-reload>
<label>Name
<input name="name" placeholder="Lobby loop" required />
</label>
<button type="submit">Create playlist</button>
</form>
</div>
@@ -28,7 +28,11 @@
</label>
<label>
Background image
<input type="file" name="background_image" id="background-image" accept="image/*" />
<div class="template-background-row">
<input type="file" name="background_image" id="background-image" accept="image/*" />
<button type="button" class="secondary" id="remove-background-image">Remove background</button>
</div>
<input type="checkbox" name="remove_background_image" id="remove-background-image-flag" value="1" hidden />
</label>
</div>
</div>
-24
View File
@@ -1,24 +0,0 @@
<div class="page-header">
<div>
<h2>Create playlist</h2>
<p>Name the playlist and start adding slides.</p>
</div>
<a class="button-link" href="/admin/playlists">Back to playlists</a>
</div>
<div class="card">
<form method="post" action="/admin/playlists" data-async-save>
<label>Name
<input name="name" placeholder="Lobby loop" required />
</label>
<div class="switch-field">
<span class="switch-label">Fade:</span>
<label class="switch-control">
<input type="checkbox" name="fade_between_slides" value="1" />
<span class="switch-ui" aria-hidden="true"></span>
</label>
</div>
<div class="muted">Disabled by default.</div>
<button type="submit">Create playlist</button>
</form>
</div>