This PR breaks the large web and player bootstrap files into smaller modules with clearer ownership.

Web changes:

Split shared helpers, bootstrap logic, route groups, and upload-sync behavior out of web.js.
Kept web.js focused on wiring and server startup.
Fixed screen playlist reassignment so changing a screen’s playlist now triggers a refresh.
Fixed single-slide playlist refresh behavior so updates do not get stuck behind the current slide.
Player changes:

Split websocket/runtime handling into runtime.js.
Split playlist assembly and revision hashing into playlist.js.
Split onboarding and player HTTP routes into dedicated modules.
Split render utilities and template loading into render-helpers.js.
Kept player.js mostly as startup/orchestration.
Validation:

Rebuilt both services with Docker Compose.
Smoke-checked web and player routes after the refactor.
Verified get_errors was clean on the touched modules.
This commit is contained in:
2026-07-20 23:58:27 +01:00
parent 480ccdbe9c
commit 2ea8d389fa
321 changed files with 12687 additions and 7080 deletions
+358 -48
View File
@@ -21,7 +21,8 @@ import {
Subscript,
Superscript,
Underline
} from '/assets/js/ckeditor5/ckeditor5.js';
} from '/assets/js/vendor/ckeditor5/ckeditor5.js';
import { createTemplateSelectorLockController } from '/assets/js/slides/slide-form-template-lock.js';
(function () {
var dataElement = document.getElementById('slide-editor-data');
@@ -42,13 +43,25 @@ import {
var templateSelect = document.getElementById('template-select');
var templateFields = document.getElementById('template-fields');
var slideForm = document.getElementById('slide-form');
var slideEditorShell = document.querySelector('.slide-editor-shell');
var slideEditorSidebar = document.querySelector('.slide-editor-sidebar');
var slidePreviewMeta = document.getElementById('slide-preview-meta');
var slidePreviewStage = document.getElementById('slide-preview-stage');
var slidePreviewCanvas = document.getElementById('slide-preview-canvas');
var slidePreviewBackground = document.getElementById('slide-preview-background');
var slidePreviewEmpty = document.getElementById('slide-preview-empty');
var slidePreviewOverlay = document.getElementById('slide-preview-overlay');
var openPreviewPopupButton = document.getElementById('open-preview-popup');
var editorInstances = new Map();
var submitting = false;
var previewRenderFrame = 0;
var previewPopupWindow = null;
var previewPopupRenderFrame = 0;
var currentPreviewCanvasWidth = 0;
var currentPreviewCanvasHeight = 0;
var sidebarSyncFrame = 0;
var sidebarTopOffset = 16;
var templateSelectorLock = createTemplateSelectorLockController(templateSelect);
class FontSizeInputUI extends Plugin {
static get pluginName() {
@@ -211,6 +224,21 @@ import {
return String(value || '');
}
function requestPreviewRender() {
if (previewRenderFrame) {
return;
}
previewRenderFrame = window.requestAnimationFrame(function () {
previewRenderFrame = 0;
renderPreview();
});
}
function getTemplateFieldControls() {
return templateFields ? templateFields.querySelectorAll('input, textarea, select') : [];
}
function sanitizePreviewHtml(html) {
var output = String(html || '');
output = output.replace(/<script[\s\S]*?<\/script>/gi, '');
@@ -445,30 +473,221 @@ import {
};
}
function getPreviewCanvasDimensions(template) {
return {
width: Math.max(1, Number(template && template.canvas_size_width ? template.canvas_size_width : 1920)),
height: Math.max(1, Number(template && template.canvas_size_height ? template.canvas_size_height : 1080))
};
}
function buildPreviewPopupHtml() {
return '<!doctype html>' +
'<html>' +
'<head>' +
'<meta charset="utf-8" />' +
'<meta name="viewport" content="width=device-width, initial-scale=1" />' +
'<title>Slide preview</title>' +
'<style>' +
'html,body{margin:0;width:100%;height:100%;overflow:hidden;background:#111;color:#fff;font-family:Arial,sans-serif;}' +
'#popup-preview-stage{position:relative;width:100%;height:100%;overflow:hidden;background:#111;}' +
'#popup-preview-canvas{position:absolute;top:0;left:0;transform-origin:top left;will-change:transform;}' +
'#popup-preview-canvas,#popup-preview-stage{box-sizing:border-box;}' +
'.slide-preview-background,.slide-preview-overlay,.slide-preview-empty{position:absolute;inset:0;}' +
'.slide-preview-background{width:100%;height:100%;object-fit:contain;display:block;z-index:1;}' +
'.slide-preview-overlay{z-index:2;}' +
'.slide-preview-empty{display:flex;align-items:center;justify-content:center;padding:1rem;text-align:center;color:rgba(255,255,255,0.75);z-index:0;}' +
'.slide-preview-region{position:absolute;overflow:hidden;box-sizing:border-box;}' +
'.slide-preview-image{width:100%;height:100%;object-fit:contain;display:block;}' +
'.slide-preview-placeholder{width:100%;height:100%;display:flex;align-items:center;justify-content:center;color:rgba(255,255,255,0.65);background:rgba(255,255,255,0.06);font-size:0.9rem;}' +
'</style>' +
'</head>' +
'<body>' +
'<div id="popup-preview-stage">' +
'<div id="popup-preview-canvas"></div>' +
'</div>' +
'</body>' +
'</html>';
}
function syncPopupPreview() {
if (!previewPopupWindow || previewPopupWindow.closed) {
previewPopupWindow = null;
return;
}
var popupDocument = previewPopupWindow.document;
if (!popupDocument) {
return;
}
var popupStage = popupDocument.getElementById('popup-preview-stage');
var popupCanvas = popupDocument.getElementById('popup-preview-canvas');
var template = getSelectedTemplate();
if (!popupStage || !popupCanvas) {
if (previewPopupRenderFrame) {
cancelAnimationFrame(previewPopupRenderFrame);
}
previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview);
return;
}
if (!template) {
popupStage.style.backgroundColor = '#111111';
popupCanvas.style.width = '';
popupCanvas.style.height = '';
popupCanvas.style.transform = '';
popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
var popupEmpty = popupCanvas.querySelector('#slide-preview-empty');
var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay');
var popupBackground = popupCanvas.querySelector('#slide-preview-background');
if (popupBackground) {
popupBackground.style.display = 'none';
}
if (popupEmpty) {
popupEmpty.style.display = 'flex';
popupEmpty.textContent = 'Select a template to preview the slide.';
}
if (popupOverlay) {
popupOverlay.innerHTML = '';
}
return;
}
var canvasWidth = currentPreviewCanvasWidth || Math.max(1, Number(template.canvas_size_width || 1920));
var canvasHeight = currentPreviewCanvasHeight || Math.max(1, Number(template.canvas_size_height || 1080));
popupStage.style.backgroundColor = template.background_color || '#111111';
popupCanvas.style.width = canvasWidth + 'px';
popupCanvas.style.height = canvasHeight + 'px';
popupCanvas.innerHTML = slidePreviewCanvas.innerHTML;
var popupBackground = popupCanvas.querySelector('#slide-preview-background');
var popupEmpty = popupCanvas.querySelector('#slide-preview-empty');
var popupOverlay = popupCanvas.querySelector('#slide-preview-overlay');
if (popupBackground) {
popupBackground.style.display = template.background_image_path ? 'block' : 'none';
popupBackground.src = template.background_image_path || '';
}
if (popupEmpty) {
popupEmpty.style.display = 'none';
}
var stageRect = popupStage.getBoundingClientRect();
if (!stageRect.width || !stageRect.height) {
if (previewPopupRenderFrame) {
cancelAnimationFrame(previewPopupRenderFrame);
}
previewPopupRenderFrame = requestAnimationFrame(syncPopupPreview);
return;
}
var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
popupCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
}
function openPreviewPopup() {
if (previewPopupWindow && !previewPopupWindow.closed) {
previewPopupWindow.focus();
syncPopupPreview();
return;
}
var popupWidth = 1280;
var popupHeight = 900;
var left = Math.max(0, Math.round((window.screen.width - popupWidth) / 2));
var top = Math.max(0, Math.round((window.screen.height - popupHeight) / 2));
var popupFeatures = [
'popup=yes',
'toolbar=no',
'location=no',
'menubar=no',
'directories=no',
'status=no',
'scrollbars=yes',
'resizable=yes',
'width=' + popupWidth,
'height=' + popupHeight,
'left=' + left,
'top=' + top
].join(',');
previewPopupWindow = window.open('', 'slide-preview-popup', popupFeatures);
if (!previewPopupWindow) {
return;
}
previewPopupWindow.document.open();
previewPopupWindow.document.write(buildPreviewPopupHtml());
previewPopupWindow.document.close();
previewPopupWindow.addEventListener('resize', syncPopupPreview);
previewPopupWindow.focus();
syncPopupPreview();
}
function syncSlideEditorSidebar() {
sidebarSyncFrame = 0;
if (!slideEditorShell || !slideEditorSidebar) {
return;
}
if (window.matchMedia && window.matchMedia('(max-width: 1199.98px)').matches) {
slideEditorSidebar.style.transform = '';
return;
}
var shellRect = slideEditorShell.getBoundingClientRect();
var sidebarHeight = slideEditorSidebar.offsetHeight;
var shellTop = window.scrollY + shellRect.top;
var shellBottom = shellTop + slideEditorShell.offsetHeight;
var maxTranslate = Math.max(0, shellBottom - shellTop - sidebarHeight - sidebarTopOffset);
var desiredTranslate = Math.max(0, Math.min(window.scrollY + sidebarTopOffset - shellTop, maxTranslate));
slideEditorSidebar.style.transform = desiredTranslate ? 'translate3d(0, ' + desiredTranslate + 'px, 0)' : '';
}
function requestSidebarSync() {
if (sidebarSyncFrame) {
return;
}
sidebarSyncFrame = window.requestAnimationFrame(syncSlideEditorSidebar);
}
function renderPreview() {
var template = getSelectedTemplate();
if (!template) {
slidePreviewMeta.textContent = 'Select a template to preview the slide.';
slidePreviewMeta.textContent = '--';
slidePreviewBackground.style.display = 'none';
slidePreviewEmpty.style.display = 'flex';
slidePreviewEmpty.textContent = 'Select a template to preview the slide.';
slidePreviewCanvas.style.width = '';
slidePreviewCanvas.style.height = '';
slidePreviewOverlay.innerHTML = '';
slidePreviewOverlay.style.width = '';
slidePreviewOverlay.style.height = '';
slidePreviewOverlay.style.transform = '';
slidePreviewStage.style.aspectRatio = '16 / 9';
slidePreviewStage.style.backgroundColor = '#111111';
return;
}
var canvasWidth = Math.max(1, Number(template.canvas_size_width || 1920));
var canvasHeight = Math.max(1, Number(template.canvas_size_height || 1080));
var canvasSize = getPreviewCanvasDimensions(template);
var canvasWidth = canvasSize.width;
var canvasHeight = canvasSize.height;
currentPreviewCanvasWidth = canvasWidth;
currentPreviewCanvasHeight = canvasHeight;
slidePreviewStage.style.aspectRatio = canvasWidth + ' / ' + canvasHeight;
slidePreviewMeta.textContent = template.name + ' • ' + canvasWidth + 'x' + canvasHeight;
slidePreviewStage.style.backgroundColor = template.background_color || '#111111';
slidePreviewMeta.textContent = canvasWidth + 'x' + canvasHeight;
if (!(template.regions || []).length) {
slidePreviewEmpty.style.display = 'flex';
slidePreviewEmpty.textContent = 'This template has no regions.';
slidePreviewBackground.style.display = template.background_image_path ? 'block' : 'none';
slidePreviewBackground.src = template.background_image_path || '';
slidePreviewCanvas.style.width = canvasWidth + 'px';
slidePreviewCanvas.style.height = canvasHeight + 'px';
slidePreviewCanvas.style.transform = '';
slidePreviewOverlay.innerHTML = '';
slidePreviewOverlay.style.width = '';
slidePreviewOverlay.style.height = '';
@@ -481,11 +700,21 @@ import {
slidePreviewBackground.src = template.background_image_path || '';
var stageRect = slidePreviewStage.getBoundingClientRect();
if (!stageRect.width || !stageRect.height) {
if (previewRenderFrame) {
cancelAnimationFrame(previewRenderFrame);
}
previewRenderFrame = requestAnimationFrame(function () {
previewRenderFrame = 0;
renderPreview();
});
return;
}
var scale = Math.min(stageRect.width / canvasWidth, stageRect.height / canvasHeight) || 1;
slidePreviewOverlay.style.width = canvasWidth + 'px';
slidePreviewOverlay.style.height = canvasHeight + 'px';
slidePreviewOverlay.style.transform = 'scale(' + scale + ')';
slidePreviewOverlay.style.transformOrigin = 'top left';
slidePreviewCanvas.style.width = canvasWidth + 'px';
slidePreviewCanvas.style.height = canvasHeight + 'px';
slidePreviewCanvas.style.transform = 'translate3d(0, 0, 0) scale(' + scale + ')';
slidePreviewCanvas.style.transformOrigin = 'top left';
var regions = template.regions || [];
slidePreviewOverlay.innerHTML = regions.map(function (region) {
var box = getRegionBox(region, canvasWidth, canvasHeight);
@@ -525,79 +754,93 @@ import {
: ' slide-preview-text-region';
return '<div class="slide-preview-region' + selected + '" style="left:' + box.left + 'px;top:' + box.top + 'px;width:' + box.width + 'px;height:' + box.height + 'px;">' + content + '</div>';
}).join('');
syncPopupPreview();
}
function renderTextRegion(region) {
var current = getCurrentRegionValue(region);
return '' +
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
'<div class="template-field-head">' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Text</span>' +
'<div class="template-field-actions">' +
'<button type="button" class="btn btn-sm btn-outline-secondary template-editor-toggle" data-toggle-editor-height="' + region.id + '">Expand</button>' +
'<span class="chip">Text</span>' +
'</div>' +
'</div>' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<div class="ckeditor-holder" data-region-id="' + region.id + '">' +
'<textarea class="ckeditor-source" rows="10">' + escapeHtml(current) + '</textarea>' +
'</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current) + '" />' +
'</div>' +
'<input type="hidden" name="region_font_size_' + region.id + '" value="' + escapeHtml(getCurrentTextStyle(region).font_size) + '" />' +
'<input type="hidden" name="region_text_' + region.id + '" value="' + escapeHtml(current) + '" />' +
'</div>';
}
function renderWebpageRegion(region) {
var current = getCurrentRegionValue(region);
return '' +
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
'<div class="template-field-head">' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Webpage</span>' +
'</div>' +
'<label style="display:block; margin-top:12px;">Webpage URL' +
'<input type="url" name="region_webpage_' + region.id + '" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
'</label>' +
'<div class="muted slide-image-file">The webpage will be loaded in an iframe.</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<label style="display:block;">Webpage URL' +
'<input type="url" name="region_webpage_' + region.id + '" class="form-control" value="' + escapeHtml(current) + '" placeholder="https://example.com" />' +
'</label>' +
'<div class="muted slide-image-file">The webpage will be loaded in an iframe.</div>' +
'</div>' +
'</div>';
}
function renderHtmlRegion(region) {
var current = getCurrentRegionValue(region);
return '' +
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
'<div class="template-field-head">' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">HTML</span>' +
'</div>' +
'<label style="display:block; margin-top:12px;">HTML content' +
'<textarea name="region_html_' + region.id + '" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
'</label>' +
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' +
'<div class="card-body p-3 d-grid gap-3">' +
'<label style="display:block;">HTML content' +
'<textarea name="region_html_' + region.id + '" class="form-control" rows="10" placeholder="<div>Hello</div>">' + escapeHtml(current) + '</textarea>' +
'</label>' +
'<div class="muted slide-image-file">HTML is rendered in a sandboxed iframe preview.</div>' +
'</div>' +
'</div>';
}
function renderImageRegion(region) {
var current = getCurrentRegionValue(region);
return '' +
'<div class="card template-field-card" data-region-id="' + region.id + '">' +
'<div class="template-field-head">' +
'<div class="card card-outline card-secondary admin-form-card template-field-card mb-3" data-region-id="' + region.id + '">' +
'<div class="card-header template-field-head">' +
'<strong>' + escapeHtml(region.label) + '</strong>' +
'<span class="chip">Image</span>' +
'</div>' +
'<label style="display:block; margin-top:12px;">Image file' +
'<input type="file" name="region_image_' + region.id + '" accept="image/*" />' +
'</label>' +
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
(current
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
: '<div class="muted slide-image-file">No image selected.</div>') +
'<div class="card-body p-3 d-grid gap-3">' +
'<label style="display:block;">Image file' +
'<input type="file" name="region_image_' + region.id + '" class="form-control" accept="image/*" />' +
'</label>' +
'<input type="hidden" name="existing_region_image_' + region.id + '" value="' + escapeHtml(current) + '" />' +
(current
? '<div class="muted slide-image-file">Current: <a href="' + escapeHtml(current) + '" target="_blank" rel="noreferrer">' + escapeHtml(current) + '</a></div>'
: '<div class="muted slide-image-file">No image selected.</div>') +
'</div>' +
'</div>';
}
function renderTemplate() {
templateSelectorLock.reset();
clearImagePreviewUrls();
destroyEditors();
var template = getTemplateById(templateSelect.value);
if (!template) {
templateFields.innerHTML = '';
renderPreview();
requestPreviewRender();
return;
}
@@ -712,14 +955,28 @@ import {
if (hidden) {
hidden.value = editor.getData();
}
renderPreview();
templateSelectorLock.markEdited();
requestPreviewRender();
});
renderPreview();
requestPreviewRender();
}).catch(function (error) {
console.error('Failed to initialize CKEditor.', error);
});
});
templateFields.querySelectorAll('[data-toggle-editor-height]').forEach(function (button) {
button.addEventListener('click', function () {
var regionId = button.getAttribute('data-toggle-editor-height');
var card = templateFields.querySelector('[data-region-id="' + regionId + '"]');
if (!card) {
return;
}
var expanded = card.classList.toggle('is-expanded');
button.textContent = expanded ? 'Collapse' : 'Expand';
});
});
templateFields.querySelectorAll('input[type="file"][name^="region_image_"]').forEach(function (input) {
input.addEventListener('change', function () {
if (input.dataset.previewUrl) {
@@ -729,10 +986,14 @@ import {
if (input.files && input.files.length) {
input.dataset.previewUrl = URL.createObjectURL(input.files[0]);
}
renderPreview();
templateSelectorLock.markEdited();
requestPreviewRender();
});
});
renderPreview();
window.requestAnimationFrame(function () {
templateSelectorLock.arm();
});
requestPreviewRender();
}
slideForm.addEventListener('submit', async function (event) {
@@ -740,6 +1001,9 @@ import {
return;
}
event.preventDefault();
var submitterValue = event.submitter && event.submitter.name === 'save_action'
? String(event.submitter.value || '').trim().toLowerCase()
: '';
var saves = Array.prototype.map.call(templateFields.querySelectorAll('.ckeditor-holder'), function (holder) {
var regionId = holder.getAttribute('data-region-id');
var editor = editorInstances.get(regionId);
@@ -753,13 +1017,17 @@ import {
submitting = true;
try {
await Promise.all(saves);
var formData = new FormData(slideForm);
if (event.submitter && event.submitter.name) {
formData.set(event.submitter.name, event.submitter.value || '');
}
var response = await fetch(slideForm.action, {
method: (slideForm.method || 'POST').toUpperCase(),
headers: {
'X-Requested-With': 'XMLHttpRequest',
'Accept': 'text/html, application/json, text/plain, */*'
},
body: new FormData(slideForm),
body: formData,
credentials: 'same-origin'
});
@@ -767,6 +1035,14 @@ import {
throw new Error(await response.text() || 'Unable to save slide.');
}
if (submitterValue === 'close' || submitterValue === 'new') {
var redirectUrl = submitterValue === 'close'
? String(slideForm.dataset.asyncSaveCloseUrl || response.url || slideForm.action)
: String(slideForm.dataset.asyncSaveNewUrl || response.url || slideForm.action);
window.location.replace(redirectUrl);
return;
}
var responseText = await response.text();
var savedMessage = '';
try {
@@ -779,19 +1055,53 @@ import {
savedMessage = '';
}
showToast(savedMessage || 'Saved slide.');
window.location.href = response.url || slideForm.action;
showToast(savedMessage || 'Saved slide.', 'success');
slideForm.dataset.dirty = 'false';
} finally {
submitting = false;
}
});
slideForm.addEventListener('input', function () {
slideForm.dataset.dirty = 'true';
}, true);
slideForm.addEventListener('change', function () {
slideForm.dataset.dirty = 'true';
}, true);
if (openPreviewPopupButton) {
openPreviewPopupButton.addEventListener('click', function () {
openPreviewPopup();
});
}
templateSelect.addEventListener('change', renderTemplate);
templateFields.addEventListener('change', renderPreview);
templateFields.addEventListener('input', renderPreview);
templateFields.addEventListener('change', function () {
templateSelectorLock.markEdited();
requestPreviewRender();
});
templateFields.addEventListener('input', function () {
templateSelectorLock.markEdited();
requestPreviewRender();
});
if (existingTemplateId) {
templateSelect.value = String(existingTemplateId);
}
renderTemplate();
window.addEventListener('resize', renderPreview);
if (window.ResizeObserver && slidePreviewStage) {
var previewResizeObserver = new ResizeObserver(function () {
requestPreviewRender();
});
previewResizeObserver.observe(slidePreviewStage);
}
requestSidebarSync();
window.addEventListener('scroll', requestSidebarSync, { passive: true });
window.addEventListener('resize', requestPreviewRender);
window.addEventListener('resize', requestSidebarSync);
window.addEventListener('beforeunload', function () {
if (previewPopupWindow && !previewPopupWindow.closed) {
previewPopupWindow.close();
}
});
})();