Refactor web server and release workflow
This commit is contained in:
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
@@ -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) {
|
||||
Vendored
Vendored
Vendored
Vendored
@@ -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) {
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user