Update app

This commit is contained in:
2026-01-25 12:03:08 +01:00
parent 4df004c18a
commit a5fe0f73a0
10 changed files with 611 additions and 54 deletions

View File

@@ -72,16 +72,22 @@
</div>
<div class="d-flex flex-column gap-2 mt-auto">
<select
class="form-select form-select-sm js-playlist-select"
data-display-id="{{ d.id }}"
aria-label="Playlist selection"
>
<option value="">(none)</option>
{% for p in playlists %}
<option value="{{ p.id }}" {% if d.assigned_playlist_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
{# Multi-playlist selector: button opens modal with playlist checkboxes #}
<div class="d-flex gap-2 align-items-center">
<button
type="button"
class="btn btn-ink btn-sm js-edit-playlists"
data-display-id="{{ d.id }}"
data-display-name="{{ d.name }}"
data-legacy-playlist-id="{{ d.assigned_playlist_id or '' }}"
data-active-playlist-ids="{{ d.display_playlists | map(attribute='playlist_id') | list | join(',') }}"
>
Select playlists
</button>
<div class="small text-muted">
<span class="js-active-playlists-summary" data-display-id="{{ d.id }}"></span>
</div>
</div>
<div class="d-flex justify-content-end">
<button
@@ -136,11 +142,43 @@
</div>
</div>
</div>
<!-- Edit playlists modal -->
<div class="modal fade" id="editPlaylistsModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="editPlaylistsModalTitle">Select playlists</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="text-muted small mb-2">Tick the playlists that should be active on this display.</div>
<div id="editPlaylistsList" class="d-flex flex-column gap-2"></div>
<div class="form-text mt-2" id="editPlaylistsHint"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-brand" id="editPlaylistsSaveBtn">Save</button>
</div>
</div>
</div>
</div>
{# Embed playlists list as JSON to avoid templating inside JS (keeps JS linters happy). #}
<script type="application/json" id="allPlaylistsJson">{{ playlists_json|tojson }}</script>
{% endblock %}
{% block page_scripts %}
<script>
(function () {
let ALL_PLAYLISTS = [];
try {
const el = document.getElementById('allPlaylistsJson');
ALL_PLAYLISTS = el ? JSON.parse(el.textContent || '[]') : [];
} catch (e) {
ALL_PLAYLISTS = [];
}
const toastEl = document.getElementById('companyToast');
const toastBodyEl = document.getElementById('companyToastBody');
const toast = toastEl ? new bootstrap.Toast(toastEl, { delay: 2200 }) : null;
@@ -187,24 +225,66 @@
}
}
// Playlist auto-save
document.querySelectorAll('.js-playlist-select').forEach((sel) => {
sel.addEventListener('change', async () => {
const displayId = sel.dataset.displayId;
const playlistId = sel.value || null;
sel.disabled = true;
try {
await postDisplayUpdate(displayId, { playlist_id: playlistId });
showToast('Playlist saved', 'text-bg-success');
refreshPreviewIframe(displayId);
} catch (e) {
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
} finally {
sel.disabled = false;
}
});
function parseIds(csv) {
const s = (csv || '').trim();
if (!s) return [];
return s.split(',').map(x => parseInt(x, 10)).filter(n => Number.isFinite(n));
}
function computeActiveIdsFromDataset(btn) {
// If display_playlist table has rows, we use that.
// Otherwise fall back to legacy single playlist assignment.
const active = parseIds(btn.dataset.activePlaylistIds);
if (active.length) return active;
const legacy = parseInt(btn.dataset.legacyPlaylistId || '', 10);
return Number.isFinite(legacy) ? [legacy] : [];
}
function setActiveIdsOnButton(btn, ids) {
btn.dataset.activePlaylistIds = (ids || []).join(',');
}
function playlistNameById(id) {
const p = (ALL_PLAYLISTS || []).find(x => x.id === id);
return p ? p.name : null;
}
function refreshActivePlaylistSummary(displayId, ids) {
const el = document.querySelector(`.js-active-playlists-summary[data-display-id="${displayId}"]`);
if (!el) return;
if (!ids || ids.length === 0) {
el.textContent = '(none)';
return;
}
const names = ids.map(playlistNameById).filter(Boolean);
el.textContent = names.length ? names.join(', ') : `${ids.length} selected`;
}
// Initialize summary labels on page load.
document.querySelectorAll('.js-edit-playlists').forEach((btn) => {
const displayId = btn.dataset.displayId;
const ids = computeActiveIdsFromDataset(btn);
refreshActivePlaylistSummary(displayId, ids);
});
async function postDisplayPlaylists(displayId, playlistIds) {
const res = await fetch(`/company/displays/${displayId}/playlists`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest'
},
body: JSON.stringify({ playlist_ids: playlistIds })
});
const data = await res.json().catch(() => null);
if (!res.ok || !data || !data.ok) {
const msg = (data && data.error) ? data.error : 'Save failed';
throw new Error(msg);
}
return data.display;
}
// Description modal
const modalEl = document.getElementById('editDescModal');
const modal = modalEl ? new bootstrap.Modal(modalEl) : null;
@@ -264,6 +344,95 @@
}
});
}
// Playlists modal
const plModalEl = document.getElementById('editPlaylistsModal');
const plModal = plModalEl ? new bootstrap.Modal(plModalEl) : null;
const plTitleEl = document.getElementById('editPlaylistsModalTitle');
const plListEl = document.getElementById('editPlaylistsList');
const plHintEl = document.getElementById('editPlaylistsHint');
const plSaveBtn = document.getElementById('editPlaylistsSaveBtn');
let activePlDisplayId = null;
let activePlButton = null;
function renderPlaylistCheckboxes(selectedIds) {
if (!plListEl) return;
plListEl.innerHTML = '';
const selectedSet = new Set(selectedIds || []);
const pls = (ALL_PLAYLISTS || []).slice().sort((a,b) => (a.name || '').localeCompare(b.name || ''));
if (pls.length === 0) {
plListEl.innerHTML = '<div class="text-muted">No playlists available.</div>';
return;
}
pls.forEach((p) => {
const id = `pl_cb_${p.id}`;
const row = document.createElement('div');
row.className = 'form-check';
const input = document.createElement('input');
input.className = 'form-check-input';
input.type = 'checkbox';
input.id = id;
input.value = String(p.id);
input.checked = selectedSet.has(p.id);
const label = document.createElement('label');
label.className = 'form-check-label';
label.setAttribute('for', id);
label.textContent = p.name;
row.appendChild(input);
row.appendChild(label);
plListEl.appendChild(row);
});
}
function getSelectedPlaylistIdsFromModal() {
if (!plListEl) return [];
return Array.from(plListEl.querySelectorAll('input[type="checkbox"]'))
.filter(cb => cb.checked)
.map(cb => parseInt(cb.value, 10))
.filter(n => Number.isFinite(n));
}
document.querySelectorAll('.js-edit-playlists').forEach((btn) => {
btn.addEventListener('click', () => {
activePlDisplayId = btn.dataset.displayId;
activePlButton = btn;
const displayName = btn.dataset.displayName || 'Display';
if (plTitleEl) plTitleEl.textContent = `Select playlists — ${displayName}`;
const selected = computeActiveIdsFromDataset(btn);
renderPlaylistCheckboxes(selected);
if (plHintEl) {
plHintEl.textContent = selected.length ? `${selected.length} currently selected.` : 'No playlists currently selected.';
}
if (plModal) plModal.show();
});
});
async function savePlaylists() {
if (!activePlDisplayId || !activePlButton || !plSaveBtn) return;
const ids = getSelectedPlaylistIdsFromModal();
plSaveBtn.disabled = true;
try {
const updated = await postDisplayPlaylists(activePlDisplayId, ids);
const newIds = (updated && updated.active_playlist_ids) ? updated.active_playlist_ids : ids;
setActiveIdsOnButton(activePlButton, newIds);
refreshActivePlaylistSummary(activePlDisplayId, newIds);
showToast('Playlists saved', 'text-bg-success');
refreshPreviewIframe(activePlDisplayId);
if (plModal) plModal.hide();
} catch (e) {
showToast(e && e.message ? e.message : 'Save failed', 'text-bg-danger');
} finally {
plSaveBtn.disabled = false;
}
}
if (plSaveBtn) {
plSaveBtn.addEventListener('click', savePlaylists);
}
})();
</script>
{% endblock %}

View File

@@ -369,6 +369,7 @@
<script type="module">
(function() {
// Keep the card layout in ONE place to ensure newly-added items match server-rendered items.
// -------------------------
// Add-item modal + steps
// -------------------------
@@ -880,7 +881,7 @@
<strong>#${i.position}</strong>
${badge}
</div>
${safeTitle ? `<div class="small">${safeTitle}</div>` : ''}
${safeTitle ? `<div class="small">${safeTitle}</div>` : `<div class="small">.</div>`}
</div>
</div>
<form method="post" action="${deleteAction}" onsubmit="return confirm('Delete item?');">
@@ -888,11 +889,11 @@
</form>
</div>
<div class="card-body">
<div class="thumb">${thumb}</div>
<div class="text-muted small d-flex align-items-center gap-2 flex-wrap">
<!-- Intentionally do NOT show file names or URLs for privacy/clean UI -->
${durationInput}
</div>
<div class="thumb">${thumb}</div>
</div>
`;
}

View File

@@ -7,16 +7,62 @@
<style>
html, body { height: 100%; width: 100%; margin: 0; background: #000; overflow: hidden; }
#stage { position: fixed; inset: 0; width: 100vw; height: 100vh; background: #000; }
#notice {
position: fixed;
inset: 0;
display: none;
align-items: center;
justify-content: center;
padding: 24px;
color: #fff;
background: rgba(0, 0, 0, 0.86);
font-family: system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif;
z-index: 10;
text-align: center;
}
#notice .box {
max-width: 720px;
}
#notice .title {
font-size: 28px;
font-weight: 700;
margin: 0 0 10px;
}
#notice .msg {
font-size: 18px;
line-height: 1.4;
margin: 0;
opacity: 0.95;
}
img, video, iframe { width: 100%; height: 100%; object-fit: contain; border: 0; }
/* removed bottom-left status text */
</style>
</head>
<body>
<div id="notice" role="alert" aria-live="assertive">
<div class="box">
<p class="title" id="noticeTitle">Notice</p>
<p class="msg" id="noticeText"></p>
</div>
</div>
<div id="stage"></div>
<script>
const token = "{{ display.token }}";
const stage = document.getElementById('stage');
function setNotice(_text) { /* intentionally no-op: notice UI removed */ }
const noticeEl = document.getElementById('notice');
const noticeTitleEl = document.getElementById('noticeTitle');
const noticeTextEl = document.getElementById('noticeText');
function setNotice(text, { title } = {}) {
const t = (text || '').trim();
if (!t) {
noticeEl.style.display = 'none';
noticeTextEl.textContent = '';
return;
}
noticeTitleEl.textContent = title || 'Notice';
noticeTextEl.textContent = t;
noticeEl.style.display = 'flex';
}
const isPreview = new URLSearchParams(window.location.search).get('preview') === '1';
@@ -55,7 +101,7 @@
function next() {
if (!playlist || !playlist.items || playlist.items.length === 0) {
setNotice('No playlist assigned.');
setNotice('No playlists assigned.');
clearStage();
return;
}
@@ -64,7 +110,7 @@
idx = (idx + 1) % playlist.items.length;
clearStage();
setNotice(playlist.playlist ? `${playlist.display}${playlist.playlist.name}` : playlist.display);
setNotice('');
if (item.type === 'image') {
const el = document.createElement('img');
@@ -108,7 +154,14 @@
next();
} catch (e) {
clearStage();
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
if (e && e.code === 'LIMIT') {
setNotice(
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
{ title: 'Display limit reached' }
);
} else {
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
}
// keep retrying; if a slot frees up the display will start automatically.
}
@@ -138,7 +191,14 @@
}
} catch(e) {
clearStage();
setNotice(e && e.message ? e.message : 'Unable to load playlist.');
if (e && e.code === 'LIMIT') {
setNotice(
(e && e.message) ? e.message : 'This display cannot start because the concurrent display limit has been reached.',
{ title: 'Display limit reached' }
);
} else {
setNotice(e && e.message ? e.message : 'Unable to load playlist.', { title: 'Playback error' });
}
}
}, pollSeconds * 1000);
}