mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-13 22:03:13 -06:00
An audit of every view turned up two problems with the in-product help. The tips only appeared on :hover. On a tablet or a phone there is no hover, so the entire explanation layer was invisible to touch users — a large share of the people administering signage — and unreachable from a keyboard. Tapping a marker now opens it, Escape or a tap elsewhere closes it, and the marker is focusable so Tab reaches it and a screen reader announces it. Bound once at the document level and applied by observing the DOM, because views render from about twenty call sites and modals appear later still; hooking each one would have left the next new route silently unreachable again. Four views had no tip at all. Playlists is the important one: a playlist is the concept the reported confusion was actually about, and the page said nothing about what one is or how it reaches a screen. Activity and Settings now have one too. Help does not, because it is the help. The schedule tip described a product that no longer exists — it said to click Add Schedule, predating the drag, resize and right-click gestures. Rewritten. All four are translated into every active locale rather than left to fall back to English, since a tip falling back is a non-English user being handed an English paragraph at the moment they are confused. hi.js stays deliberately empty per the note in that file. Tests now check that every tip is translated everywhere, and that a tip marker never names a string that does not exist. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
853 lines
44 KiB
JavaScript
853 lines
44 KiB
JavaScript
import { api } from '../api.js';
|
|
import { showToast } from '../components/toast.js';
|
|
import { esc, hydrateAuthImages } from '../utils.js';
|
|
import { t, tn } from '../i18n.js';
|
|
|
|
function formatDate(ts) {
|
|
if (!ts) return '--';
|
|
return new Date(ts * 1000).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' });
|
|
}
|
|
|
|
function getTypeIcon(item) {
|
|
if (item.widget_id) return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="2" width="8" height="8" rx="1"/><rect x="14" y="2" width="8" height="8" rx="1"/><rect x="2" y="14" width="8" height="8" rx="1"/><rect x="14" y="14" width="8" height="8" rx="1"/></svg>';
|
|
if (item.mime_type && item.mime_type.startsWith('video/')) return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="5 3 19 12 5 21 5 3"/></svg>';
|
|
return '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2" ry="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>';
|
|
}
|
|
|
|
// #74/#75 per-item schedule editor helpers. Client validation MIRRORS the server
|
|
// (server/routes/playlists.js validateBlocks): same time/date regexes, non-empty days.
|
|
const SCHED_TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
const SCHED_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
|
|
function daysSummary(days) {
|
|
const labels = t('itemsched.dow_short').split(',');
|
|
const s = [...days].sort((a, b) => a - b);
|
|
if (s.length === 7) return t('itemsched.every_day');
|
|
if (s.length === 5 && [1, 2, 3, 4, 5].every(d => s.includes(d))) return t('itemsched.mon_fri');
|
|
if (s.length === 2 && s.includes(0) && s.includes(6)) return t('itemsched.sat_sun');
|
|
return s.map(d => labels[d]).join(' ');
|
|
}
|
|
function blockSummary(b) {
|
|
let s = `${daysSummary(b.days)} ${b.start}-${b.end}`;
|
|
if (b.start_date || b.end_date) s += ` · ${b.start_date || '…'}→${b.end_date || '…'}`;
|
|
return s;
|
|
}
|
|
function scheduleSummary(schedules) {
|
|
if (!schedules || !schedules.length) return '';
|
|
return schedules.length === 1 ? blockSummary(schedules[0]) : `${blockSummary(schedules[0])} +${schedules.length - 1}`;
|
|
}
|
|
function validateScheduleBlocks(blocks) {
|
|
for (const b of blocks) {
|
|
if (!b.days || !b.days.length) return t('itemsched.err.days');
|
|
if (!SCHED_TIME_RE.test(b.start)) return t('itemsched.err.start');
|
|
if (!(SCHED_TIME_RE.test(b.end) || b.end === '24:00')) return t('itemsched.err.end');
|
|
if (b.start_date && !SCHED_DATE_RE.test(b.start_date)) return t('itemsched.err.start_date');
|
|
if (b.end_date && !SCHED_DATE_RE.test(b.end_date)) return t('itemsched.err.end_date');
|
|
}
|
|
return null;
|
|
}
|
|
|
|
let currentPlaylistId = null;
|
|
|
|
export function render(container) {
|
|
const hash = window.location.hash;
|
|
const match = hash.match(/#\/playlists\/(.+)/);
|
|
if (match) {
|
|
currentPlaylistId = match[1];
|
|
renderDetail(container, match[1]);
|
|
} else {
|
|
currentPlaylistId = null;
|
|
renderList(container);
|
|
}
|
|
}
|
|
|
|
export function cleanup() {
|
|
currentPlaylistId = null;
|
|
}
|
|
|
|
let showAutoGenerated = true;
|
|
|
|
async function renderList(container) {
|
|
container.innerHTML = `
|
|
<div class="page-header">
|
|
<div>
|
|
<h1>${t('playlist.title')} <span class="help-tip" data-tip="${t('playlist.help_tip')}">?</span></h1>
|
|
<div class="subtitle">${t('playlist.subtitle')}</div>
|
|
</div>
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<label style="display:flex;align-items:center;gap:6px;font-size:13px;color:var(--text-secondary);cursor:pointer">
|
|
<input type="checkbox" id="showAutoToggle" ${showAutoGenerated ? 'checked' : ''}>
|
|
${t('playlist.show_auto_generated')}
|
|
</label>
|
|
<button class="btn btn-primary" id="createPlaylistBtn">${t('playlist.new_playlist_btn')}</button>
|
|
</div>
|
|
</div>
|
|
<div id="playlistGrid" style="display:grid;grid-template-columns:repeat(auto-fill,minmax(320px,1fr));gap:16px">
|
|
<div style="color:var(--text-muted);padding:40px;text-align:center">${t('common.loading')}</div>
|
|
</div>
|
|
`;
|
|
|
|
document.getElementById('createPlaylistBtn').addEventListener('click', showCreateModal);
|
|
document.getElementById('showAutoToggle').addEventListener('change', (e) => {
|
|
showAutoGenerated = e.target.checked;
|
|
loadPlaylists();
|
|
});
|
|
loadPlaylists();
|
|
}
|
|
|
|
async function loadPlaylists() {
|
|
const grid = document.getElementById('playlistGrid');
|
|
if (!grid) return;
|
|
|
|
try {
|
|
const playlists = await api.getPlaylists();
|
|
if (!playlists.length) {
|
|
grid.innerHTML = `
|
|
<div style="grid-column:1/-1;text-align:center;padding:60px 20px;color:var(--text-muted)">
|
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" style="margin:0 auto 16px;display:block;opacity:0.4">
|
|
<line x1="8" y1="6" x2="21" y2="6"/><line x1="8" y1="12" x2="21" y2="12"/><line x1="8" y1="18" x2="21" y2="18"/>
|
|
<line x1="3" y1="6" x2="3.01" y2="6"/><line x1="3" y1="12" x2="3.01" y2="12"/><line x1="3" y1="18" x2="3.01" y2="18"/>
|
|
</svg>
|
|
<h3 style="margin-bottom:8px;color:var(--text-primary)">${t('playlist.empty_title')}</h3>
|
|
<p>${t('playlist.empty_desc')}</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
const filtered = showAutoGenerated ? playlists : playlists.filter(p => !p.is_auto_generated);
|
|
if (!filtered.length) {
|
|
grid.innerHTML = `
|
|
<div style="grid-column:1/-1;text-align:center;padding:40px;color:var(--text-muted)">
|
|
${playlists.length ? t('playlist.all_auto_generated') : ''}
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
grid.innerHTML = filtered.map(p => `
|
|
<a href="#/playlists/${esc(p.id)}" class="playlist-card" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px;text-decoration:none;color:inherit;display:block;transition:border-color 0.15s;cursor:pointer">
|
|
<div style="display:flex;justify-content:space-between;align-items:start;margin-bottom:12px">
|
|
<div style="display:flex;align-items:center;gap:8px">
|
|
<div style="font-size:16px;font-weight:600;color:var(--text-primary)">${esc(p.name)}</div>
|
|
${p.is_auto_generated ? `<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:var(--bg-input);color:var(--text-muted)">${t('playlist.tag_auto')}</span>` : ''}
|
|
${p.status === 'draft' ? `<span style="font-size:10px;padding:2px 6px;border-radius:4px;background:#78350f;color:#fbbf24">${t('playlist.tag_draft')}</span>` : ''}
|
|
</div>
|
|
<div style="font-size:12px;color:var(--text-muted);white-space:nowrap;margin-left:12px">${tn('playlist.item_count', p.item_count)}</div>
|
|
</div>
|
|
${p.description ? `<div style="font-size:13px;color:var(--text-secondary);margin-bottom:12px;line-height:1.4">${esc(p.description)}</div>` : ''}
|
|
<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted)">
|
|
<span>${t('playlist.created_at', { date: formatDate(p.created_at) })}</span>
|
|
${p.display_count ? `<span>${tn('playlist.display_count', p.display_count)}</span>` : ''}
|
|
</div>
|
|
</a>
|
|
`).join('');
|
|
} catch (err) {
|
|
grid.innerHTML = `<div style="grid-column:1/-1;color:var(--text-muted);padding:40px;text-align:center">${t('playlist.load_failed', { error: esc(err.message) })}</div>`;
|
|
}
|
|
}
|
|
|
|
function showCreateModal() {
|
|
const modal = document.createElement('div');
|
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
|
modal.innerHTML = `
|
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:400px;max-width:90vw">
|
|
<h3 style="margin-bottom:16px;color:var(--text-primary)">${t('playlist.new_playlist')}</h3>
|
|
<input type="text" id="newPlaylistName" class="input" placeholder="${t('playlist.name_placeholder')}" style="width:100%;margin-bottom:12px" autofocus>
|
|
<textarea id="newPlaylistDesc" class="input" placeholder="${t('playlist.desc_placeholder')}" style="width:100%;height:60px;resize:vertical;margin-bottom:16px"></textarea>
|
|
<div style="display:flex;gap:8px;justify-content:flex-end">
|
|
<button class="btn btn-secondary" id="cancelCreateBtn">${t('common.cancel')}</button>
|
|
<button class="btn btn-primary" id="confirmCreateBtn">${t('playlist.create_btn')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
const nameInput = document.getElementById('newPlaylistName');
|
|
nameInput.focus();
|
|
|
|
document.getElementById('cancelCreateBtn').addEventListener('click', () => modal.remove());
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
|
|
|
async function doCreate() {
|
|
const name = nameInput.value.trim();
|
|
if (!name) { nameInput.focus(); return; }
|
|
const desc = document.getElementById('newPlaylistDesc').value.trim();
|
|
try {
|
|
const pl = await api.createPlaylist(name, desc);
|
|
modal.remove();
|
|
showToast(t('playlist.toast.created'));
|
|
window.location.hash = `#/playlists/${pl.id}`;
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
}
|
|
|
|
document.getElementById('confirmCreateBtn').addEventListener('click', doCreate);
|
|
nameInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') doCreate(); });
|
|
}
|
|
|
|
async function renderDetail(container, playlistId) {
|
|
container.innerHTML = `
|
|
<div style="color:var(--text-muted);padding:40px;text-align:center">${t('common.loading')}</div>
|
|
`;
|
|
|
|
try {
|
|
const playlist = await api.getPlaylist(playlistId);
|
|
renderDetailContent(container, playlist);
|
|
} catch (err) {
|
|
container.innerHTML = `
|
|
<div style="padding:40px;text-align:center;color:var(--text-muted)">
|
|
<p>${t('playlist.load_failed', { error: esc(err.message) })}</p>
|
|
<a href="#/playlists" class="btn btn-secondary" style="margin-top:16px">${t('playlist.back_to_playlists')}</a>
|
|
</div>
|
|
`;
|
|
}
|
|
}
|
|
|
|
// #104: draft preview by REUSING the player. Iframes /player in device-free preview
|
|
// mode (same-origin -> dashboard CSP frame-src 'self' allows it). The player fetches
|
|
// /api/playlists/:id/preview-payload and renders with its unmodified renderer, so the
|
|
// preview is byte-identical to what a device shows. Orientation toggle just reloads
|
|
// the iframe with &orientation; the server passes it through.
|
|
function showPlaylistPreview(playlist) {
|
|
let orientation = 'landscape';
|
|
const aspect = () => (orientation.startsWith('portrait') ? '9 / 16' : '16 / 9');
|
|
const frameSrc = () => `/player?preview=1&playlist=${encodeURIComponent(playlist.id)}&orientation=${orientation}&t=${Date.now()}`;
|
|
const overlay = document.createElement('div');
|
|
overlay.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.85);display:flex;align-items:center;justify-content:center;z-index:10000;padding:16px';
|
|
overlay.innerHTML = `
|
|
<div style="background:var(--bg-card);border-radius:8px;display:flex;flex-direction:column;overflow:hidden;border:1px solid var(--border);max-width:95vw;max-height:92vh">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;padding:10px 16px;border-bottom:1px solid var(--border);gap:12px">
|
|
<strong style="color:var(--text-primary)">${t('widget.preview')} — ${esc(playlist.name)}</strong>
|
|
<div style="display:flex;gap:8px;align-items:center">
|
|
<button class="btn btn-primary btn-sm" id="pvpLandscape">${t('device.form.orientation.landscape')}</button>
|
|
<button class="btn btn-secondary btn-sm" id="pvpPortrait">${t('device.form.orientation.portrait')}</button>
|
|
<button class="btn btn-secondary btn-sm" id="pvpClose">${t('widget.close')}</button>
|
|
</div>
|
|
</div>
|
|
<div style="padding:16px;display:flex;align-items:center;justify-content:center;background:#000">
|
|
<iframe id="pvpFrame" style="height:78vh;max-width:92vw;aspect-ratio:${aspect()};border:0;background:#000" src="${frameSrc()}"></iframe>
|
|
</div>
|
|
</div>`;
|
|
document.body.appendChild(overlay);
|
|
const frame = overlay.querySelector('#pvpFrame');
|
|
const btnL = overlay.querySelector('#pvpLandscape');
|
|
const btnP = overlay.querySelector('#pvpPortrait');
|
|
const setOrientation = (o) => {
|
|
orientation = o;
|
|
frame.style.aspectRatio = aspect();
|
|
frame.src = frameSrc();
|
|
btnL.className = 'btn btn-sm ' + (o === 'landscape' ? 'btn-primary' : 'btn-secondary');
|
|
btnP.className = 'btn btn-sm ' + (o.startsWith('portrait') ? 'btn-primary' : 'btn-secondary');
|
|
};
|
|
btnL.onclick = () => setOrientation('landscape');
|
|
btnP.onclick = () => setOrientation('portrait');
|
|
const close = () => overlay.remove();
|
|
overlay.querySelector('#pvpClose').onclick = close;
|
|
overlay.onclick = (e) => { if (e.target === overlay) close(); };
|
|
document.addEventListener('keydown', function esc(ev) {
|
|
if (ev.key === 'Escape') { close(); document.removeEventListener('keydown', esc); }
|
|
});
|
|
}
|
|
|
|
function renderDetailContent(container, playlist) {
|
|
const isDraft = playlist.status === 'draft';
|
|
const hasPublished = !!playlist.published_snapshot;
|
|
|
|
container.innerHTML = `
|
|
${isDraft ? `
|
|
<div id="draftBanner" style="background:#78350f;border:1px solid #92400e;border-radius:var(--radius-lg);padding:14px 20px;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between;gap:16px">
|
|
<div style="display:flex;align-items:center;gap:10px;color:#fbbf24">
|
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/></svg>
|
|
<div>
|
|
<div style="font-weight:600;font-size:14px">${t('playlist.draft.banner_title')}</div>
|
|
<div style="font-size:12px;color:#fcd34d;opacity:0.85">${hasPublished ? t('playlist.draft.devices_showing_published') : t('playlist.draft.never_published')}</div>
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:8px;flex-shrink:0">
|
|
${hasPublished ? `<button class="btn btn-secondary btn-sm" id="discardDraftBtn" style="color:#fbbf24;border-color:#92400e">${t('playlist.draft.discard_changes')}</button>` : ''}
|
|
<button class="btn btn-sm" id="publishBtn" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('playlist.draft.publish')}</button>
|
|
</div>
|
|
</div>
|
|
` : ''}
|
|
|
|
<div class="page-header">
|
|
<div style="display:flex;align-items:center;gap:12px">
|
|
<a href="#/playlists" style="color:var(--text-muted);text-decoration:none;font-size:20px" title="${t('playlist.back')}">←</a>
|
|
<div>
|
|
<h1 id="playlistTitle" style="cursor:pointer" title="${t('playlist.click_to_rename')}">${esc(playlist.name)}</h1>
|
|
<div class="subtitle" id="playlistDesc" style="cursor:pointer" title="${t('playlist.click_to_edit_desc')}">${playlist.description ? esc(playlist.description) : `<span style="opacity:0.5">${t('playlist.add_desc_placeholder')}</span>`}</div>
|
|
${playlist.display_count ? `<div style="font-size:12px;color:var(--text-muted);margin-top:4px">${tn('playlist.assigned_to', playlist.display_count)}</div>` : ''}
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;gap:8px">
|
|
<button class="btn btn-secondary" id="previewPlaylistBtn">${t('widget.preview')}</button>
|
|
<button class="btn btn-primary" id="addItemBtn">${t('playlist.add_content')}</button>
|
|
<button class="btn btn-secondary" id="deletePlaylistBtn" style="color:var(--danger)">${t('playlist.delete_playlist')}</button>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="playlistItems" style="display:flex;flex-direction:column;gap:8px">
|
|
</div>
|
|
`;
|
|
|
|
renderItems(playlist.items || []);
|
|
|
|
const publishBtn = document.getElementById('publishBtn');
|
|
if (publishBtn) {
|
|
publishBtn.addEventListener('click', async () => {
|
|
try {
|
|
publishBtn.disabled = true;
|
|
publishBtn.textContent = t('playlist.draft.publishing');
|
|
const updated = await api.publishPlaylist(playlist.id);
|
|
showToast(t('playlist.toast.published'));
|
|
renderDetailContent(container, updated);
|
|
} catch (err) {
|
|
publishBtn.disabled = false;
|
|
publishBtn.textContent = t('playlist.draft.publish');
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
const previewBtn = document.getElementById('previewPlaylistBtn');
|
|
if (previewBtn) previewBtn.addEventListener('click', () => showPlaylistPreview(playlist));
|
|
|
|
const discardBtn = document.getElementById('discardDraftBtn');
|
|
if (discardBtn) {
|
|
discardBtn.addEventListener('click', async () => {
|
|
if (!confirm(t('playlist.confirm_discard_draft'))) return;
|
|
try {
|
|
const updated = await api.discardPlaylistDraft(playlist.id);
|
|
showToast(t('playlist.toast.draft_discarded'));
|
|
renderDetailContent(container, updated);
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
document.getElementById('playlistTitle').addEventListener('click', () => inlineEdit(playlist, 'name'));
|
|
document.getElementById('playlistDesc').addEventListener('click', () => inlineEdit(playlist, 'description'));
|
|
|
|
document.getElementById('addItemBtn').addEventListener('click', () => showAddItemModal(playlist.id));
|
|
|
|
document.getElementById('deletePlaylistBtn').addEventListener('click', async () => {
|
|
if (!confirm(t('playlist.confirm_delete', { name: playlist.name }))) return;
|
|
try {
|
|
await api.deletePlaylist(playlist.id);
|
|
showToast(t('playlist.toast.deleted'));
|
|
window.location.hash = '#/playlists';
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
}
|
|
|
|
async function refreshAfterMutation() {
|
|
if (!currentPlaylistId) return;
|
|
const mainContainer = document.getElementById('draftBanner')?.parentElement || document.querySelector('.page-header')?.parentElement;
|
|
if (!mainContainer) return;
|
|
try {
|
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
|
renderDetailContent(mainContainer, playlist);
|
|
} catch (e) { /* silent */ }
|
|
}
|
|
|
|
function renderItems(items) {
|
|
const itemsEl = document.getElementById('playlistItems');
|
|
if (!itemsEl) return;
|
|
|
|
if (!items.length) {
|
|
itemsEl.innerHTML = `
|
|
<div style="text-align:center;padding:40px;color:var(--text-muted);border:2px dashed var(--border);border-radius:var(--radius-lg)">
|
|
<p style="margin-bottom:8px">${t('playlist.items_empty')}</p>
|
|
<p style="font-size:13px">${t('playlist.items_empty_hint')}</p>
|
|
</div>
|
|
`;
|
|
return;
|
|
}
|
|
|
|
itemsEl.innerHTML = items.map((item, i) => `
|
|
<div class="playlist-item" data-item-id="${item.id}" data-index="${i}" draggable="true" style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius);padding:12px 16px;display:flex;align-items:center;gap:12px;cursor:grab;transition:border-color 0.15s">
|
|
<div style="color:var(--text-muted);font-size:12px;min-width:24px;text-align:center;user-select:none">${i + 1}</div>
|
|
<div style="width:48px;height:36px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
|
|
${item.thumbnail_path
|
|
? `<img data-auth-src="/api/content/${esc(item.content_id)}/thumbnail" style="width:100%;height:100%;object-fit:cover">`
|
|
: `<div style="color:var(--text-muted);opacity:0.5">${getTypeIcon(item)}</div>`
|
|
}
|
|
</div>
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:14px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(item.filename || item.widget_name || t('common.unknown'))}</div>
|
|
<div style="font-size:12px;color:var(--text-muted);display:flex;align-items:center;gap:8px;min-width:0">
|
|
<span style="white-space:nowrap">${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}</span>
|
|
${item.schedules && item.schedules.length ? `<span style="font-size:11px;padding:1px 6px;border-radius:4px;background:#0c2a3f;color:#7dd3fc;white-space:nowrap;overflow:hidden;text-overflow:ellipsis" title="${esc(scheduleSummary(item.schedules))}">🕐 ${esc(scheduleSummary(item.schedules))}</span>` : ''}
|
|
</div>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:8px;flex-shrink:0">
|
|
<label style="font-size:12px;color:var(--text-muted)">${t('playlist.duration')}</label>
|
|
<input type="number" class="input item-duration" data-item-id="${item.id}" value="${item.duration_sec}" min="1" style="width:60px;padding:4px 8px;font-size:13px;text-align:center">
|
|
<span style="font-size:12px;color:var(--text-muted)">${t('playlist.sec')}</span>
|
|
</div>
|
|
<div style="display:flex;align-items:center;gap:4px;flex-shrink:0">
|
|
<button class="btn-icon item-schedule" data-item-id="${item.id}" title="${t('itemsched.title')}" aria-label="${t('itemsched.title')}" style="color:${item.schedules && item.schedules.length ? '#38bdf8' : 'var(--text-muted)'};background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="9"/><polyline points="12 7 12 12 15 14"/></svg>
|
|
</button>
|
|
<button class="btn-icon item-replace" data-item-id="${item.id}" title="${t('playlist.replace_item')}" aria-label="${t('playlist.replace_item')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="17 1 21 5 17 9"/><path d="M3 11V9a4 4 0 0 1 4-4h14"/><polyline points="7 23 3 19 7 15"/><path d="M21 13v2a4 4 0 0 1-4 4H3"/></svg>
|
|
</button>
|
|
<button class="btn-icon item-duplicate" data-item-id="${item.id}" title="${t('playlist.duplicate_item')}" aria-label="${t('playlist.duplicate_item')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>
|
|
</button>
|
|
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="up" title="${t('playlist.move_up')}" aria-label="${t('playlist.move_up')}" ${i === 0 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === 0 ? 'opacity:0.3;cursor:not-allowed' : ''}">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="18 15 12 9 6 15"/></svg>
|
|
</button>
|
|
<button class="btn-icon item-move" data-item-id="${item.id}" data-dir="down" title="${t('playlist.move_down')}" aria-label="${t('playlist.move_down')}" ${i === items.length - 1 ? 'disabled' : ''} style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px;${i === items.length - 1 ? 'opacity:0.3;cursor:not-allowed' : ''}">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="6 9 12 15 18 9"/></svg>
|
|
</button>
|
|
<button class="btn-icon item-remove" data-item-id="${item.id}" title="${t('common.delete')}" aria-label="${t('playlist.remove_item')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;padding:4px;border-radius:4px">
|
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
`).join('');
|
|
hydrateAuthImages(itemsEl);
|
|
|
|
itemsEl.querySelectorAll('.item-duration').forEach(input => {
|
|
input.addEventListener('change', async (e) => {
|
|
const itemId = e.target.dataset.itemId;
|
|
const val = parseInt(e.target.value, 10);
|
|
if (!val || val < 1) { e.target.value = 10; return; }
|
|
try {
|
|
await api.updatePlaylistItem(currentPlaylistId, itemId, { duration_sec: val });
|
|
refreshAfterMutation();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
itemsEl.querySelectorAll('.item-remove').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
const itemId = e.currentTarget.dataset.itemId;
|
|
try {
|
|
await api.deletePlaylistItem(currentPlaylistId, itemId);
|
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
|
renderItems(playlist.items || []);
|
|
refreshAfterMutation();
|
|
showToast(t('playlist.toast.item_removed'));
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
itemsEl.querySelectorAll('.item-schedule').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const itemId = e.currentTarget.dataset.itemId;
|
|
const item = items.find(it => String(it.id) === String(itemId));
|
|
if (item) showScheduleModal(item);
|
|
});
|
|
});
|
|
|
|
// #105 duplicate: server copies the row + its schedule blocks, appended at the end.
|
|
itemsEl.querySelectorAll('.item-duplicate').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
const itemId = e.currentTarget.dataset.itemId;
|
|
try {
|
|
e.currentTarget.disabled = true;
|
|
await api.duplicatePlaylistItem(currentPlaylistId, itemId);
|
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
|
renderItems(playlist.items || []);
|
|
refreshAfterMutation();
|
|
showToast(t('playlist.toast.item_duplicated'));
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
// #105 replace: reuse the add-item picker in "replace" mode — swaps content/widget
|
|
// in place, preserving duration/schedule/zone (server-side).
|
|
itemsEl.querySelectorAll('.item-replace').forEach(btn => {
|
|
btn.addEventListener('click', (e) => {
|
|
const itemId = e.currentTarget.dataset.itemId;
|
|
showAddItemModal(currentPlaylistId, { replaceItemId: itemId });
|
|
});
|
|
});
|
|
|
|
itemsEl.querySelectorAll('.item-move').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
if (btn.disabled) return;
|
|
const itemId = parseInt(e.currentTarget.dataset.itemId, 10);
|
|
const dir = e.currentTarget.dataset.dir;
|
|
const order = Array.from(itemsEl.querySelectorAll('.playlist-item'))
|
|
.map(el => parseInt(el.dataset.itemId, 10));
|
|
const idx = order.indexOf(itemId);
|
|
const swap = dir === 'up' ? idx - 1 : idx + 1;
|
|
if (swap < 0 || swap >= order.length) return;
|
|
[order[idx], order[swap]] = [order[swap], order[idx]];
|
|
try {
|
|
const updated = await api.reorderPlaylistItems(currentPlaylistId, order);
|
|
renderItems(updated);
|
|
refreshAfterMutation();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
|
|
setupDragReorder(itemsEl);
|
|
}
|
|
|
|
function setupDragReorder(container) {
|
|
let dragEl = null;
|
|
|
|
container.addEventListener('dragstart', (e) => {
|
|
dragEl = e.target.closest('.playlist-item');
|
|
if (!dragEl) return;
|
|
dragEl.style.opacity = '0.4';
|
|
e.dataTransfer.effectAllowed = 'move';
|
|
});
|
|
|
|
container.addEventListener('dragend', () => {
|
|
if (dragEl) dragEl.style.opacity = '';
|
|
dragEl = null;
|
|
container.querySelectorAll('.playlist-item').forEach(el => el.style.borderTop = '');
|
|
});
|
|
|
|
container.addEventListener('dragover', (e) => {
|
|
e.preventDefault();
|
|
e.dataTransfer.dropEffect = 'move';
|
|
const target = e.target.closest('.playlist-item');
|
|
container.querySelectorAll('.playlist-item').forEach(el => el.style.borderTop = '');
|
|
if (target && target !== dragEl) {
|
|
target.style.borderTop = '2px solid var(--primary)';
|
|
}
|
|
});
|
|
|
|
container.addEventListener('drop', async (e) => {
|
|
e.preventDefault();
|
|
const target = e.target.closest('.playlist-item');
|
|
if (!target || !dragEl || target === dragEl) return;
|
|
|
|
container.insertBefore(dragEl, target);
|
|
|
|
const order = Array.from(container.querySelectorAll('.playlist-item'))
|
|
.map(el => parseInt(el.dataset.itemId, 10));
|
|
|
|
try {
|
|
const items = await api.reorderPlaylistItems(currentPlaylistId, order);
|
|
renderItems(items);
|
|
refreshAfterMutation();
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
|
renderItems(playlist.items || []);
|
|
}
|
|
});
|
|
}
|
|
|
|
function inlineEdit(playlist, field) {
|
|
const el = field === 'name' ? document.getElementById('playlistTitle') : document.getElementById('playlistDesc');
|
|
if (!el) return;
|
|
|
|
const current = playlist[field] || '';
|
|
const isName = field === 'name';
|
|
|
|
if (isName) {
|
|
const input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'input';
|
|
input.value = current;
|
|
input.style.cssText = 'font-size:24px;font-weight:700;padding:2px 8px;width:100%';
|
|
el.replaceWith(input);
|
|
input.focus();
|
|
input.select();
|
|
|
|
async function save() {
|
|
const val = input.value.trim();
|
|
if (!val) { input.value = current; return; }
|
|
try {
|
|
const updated = await api.updatePlaylist(playlist.id, { [field]: val });
|
|
playlist[field] = updated[field];
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
const newEl = document.createElement('h1');
|
|
newEl.id = 'playlistTitle';
|
|
newEl.style.cursor = 'pointer';
|
|
newEl.title = t('playlist.click_to_rename');
|
|
newEl.textContent = playlist.name;
|
|
input.replaceWith(newEl);
|
|
newEl.addEventListener('click', () => inlineEdit(playlist, 'name'));
|
|
}
|
|
|
|
input.addEventListener('blur', save);
|
|
input.addEventListener('keydown', (e) => { if (e.key === 'Enter') input.blur(); if (e.key === 'Escape') { input.value = current; input.blur(); } });
|
|
} else {
|
|
const input = document.createElement('textarea');
|
|
input.className = 'input';
|
|
input.value = current;
|
|
input.style.cssText = 'font-size:13px;padding:4px 8px;width:100%;height:40px;resize:vertical';
|
|
el.replaceWith(input);
|
|
input.focus();
|
|
|
|
async function save() {
|
|
const val = input.value.trim();
|
|
try {
|
|
const updated = await api.updatePlaylist(playlist.id, { description: val });
|
|
playlist.description = updated.description;
|
|
} catch (err) {
|
|
showToast(err.message, 'error');
|
|
}
|
|
const newEl = document.createElement('div');
|
|
newEl.className = 'subtitle';
|
|
newEl.id = 'playlistDesc';
|
|
newEl.style.cursor = 'pointer';
|
|
newEl.title = t('playlist.click_to_edit_desc');
|
|
if (playlist.description) {
|
|
newEl.textContent = playlist.description;
|
|
} else {
|
|
newEl.innerHTML = `<span style="opacity:0.5">${t('playlist.add_desc_placeholder')}</span>`;
|
|
}
|
|
input.replaceWith(newEl);
|
|
newEl.addEventListener('click', () => inlineEdit(playlist, 'description'));
|
|
}
|
|
|
|
input.addEventListener('blur', save);
|
|
input.addEventListener('keydown', (e) => { if (e.key === 'Escape') { input.value = current; input.blur(); } });
|
|
}
|
|
}
|
|
|
|
async function showAddItemModal(playlistId, opts = {}) {
|
|
// #105: when opts.replaceItemId is set, picking an item REPLACES that item's
|
|
// content/widget in place (preserving duration/schedule/zone) instead of adding.
|
|
const replaceItemId = opts.replaceItemId || null;
|
|
const modal = document.createElement('div');
|
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
|
modal.innerHTML = `
|
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;max-width:560px;width:95vw;max-height:80vh;display:flex;flex-direction:column">
|
|
<h3 style="margin-bottom:16px;color:var(--text-primary)">${replaceItemId ? t('playlist.replace_modal_title') : t('playlist.add_modal_title')}</h3>
|
|
<div style="display:flex;gap:8px;margin-bottom:12px">
|
|
<button class="btn btn-primary btn-sm tab-btn active" data-tab="content">${t('playlist.tab_content')}</button>
|
|
<button class="btn btn-secondary btn-sm tab-btn" data-tab="widgets">${t('playlist.tab_widgets')}</button>
|
|
</div>
|
|
<input type="text" id="addItemSearch" class="input" placeholder="${t('playlist.search_placeholder')}" style="width:100%;margin-bottom:12px">
|
|
<div id="addItemList" style="flex:1;overflow-y:auto;min-height:200px;max-height:400px"></div>
|
|
<div style="display:flex;justify-content:flex-end;margin-top:16px">
|
|
<button class="btn btn-secondary" id="closeAddModal">${t('playlist.close')}</button>
|
|
</div>
|
|
</div>
|
|
`;
|
|
document.body.appendChild(modal);
|
|
|
|
let activeTab = 'content';
|
|
let allContent = [];
|
|
let allWidgets = [];
|
|
|
|
try {
|
|
[allContent, allWidgets] = await Promise.all([
|
|
api.getContent(),
|
|
api.getWidgets ? api.getWidgets() : Promise.resolve([])
|
|
]);
|
|
} catch (err) {
|
|
document.getElementById('addItemList').innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">${t('playlist.load_failed', { error: esc(err.message) })}</div>`;
|
|
}
|
|
|
|
function renderTab() {
|
|
const list = document.getElementById('addItemList');
|
|
const search = (document.getElementById('addItemSearch')?.value || '').toLowerCase();
|
|
const items = activeTab === 'content' ? allContent : allWidgets;
|
|
const filtered = items.filter(item => {
|
|
const name = (item.filename || item.name || '').toLowerCase();
|
|
return name.includes(search);
|
|
});
|
|
|
|
if (!filtered.length) {
|
|
list.innerHTML = `<div style="color:var(--text-muted);padding:20px;text-align:center">${activeTab === 'content' ? t('playlist.no_content_found') : t('playlist.no_widgets_found')}</div>`;
|
|
return;
|
|
}
|
|
|
|
list.innerHTML = filtered.map(item => {
|
|
const isWidget = activeTab === 'widgets';
|
|
const name = item.filename || item.name || t('common.unknown');
|
|
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : (item.mime_type || '');
|
|
const thumb = item.thumbnail_path ? `/api/content/${esc(item.id)}/thumbnail` : null;
|
|
return `
|
|
<div class="add-item-row" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}" style="display:flex;align-items:center;gap:12px;padding:10px;border-radius:var(--radius);cursor:pointer;transition:background 0.1s">
|
|
<div style="width:40px;height:30px;border-radius:4px;overflow:hidden;background:var(--bg-input);flex-shrink:0;display:flex;align-items:center;justify-content:center">
|
|
${thumb ? `<img data-auth-src="${thumb}" style="width:100%;height:100%;object-fit:cover">` : '<div style="color:var(--text-muted);opacity:0.4"><svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="18" height="18" rx="2"/></svg></div>'}
|
|
</div>
|
|
<div style="flex:1;min-width:0">
|
|
<div style="font-size:13px;color:var(--text-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis">${esc(name)}</div>
|
|
<div style="font-size:11px;color:var(--text-muted)">${esc(sub)}</div>
|
|
</div>
|
|
<button class="btn btn-primary btn-sm add-item-btn" data-id="${esc(item.id)}" data-type="${isWidget ? 'widget' : 'content'}">${replaceItemId ? t('playlist.replace_btn') : t('playlist.add_btn')}</button>
|
|
</div>
|
|
`;
|
|
}).join('');
|
|
hydrateAuthImages(list, { eager: true });
|
|
|
|
list.querySelectorAll('.add-item-btn').forEach(btn => {
|
|
btn.addEventListener('click', async (e) => {
|
|
e.stopPropagation();
|
|
const id = btn.dataset.id;
|
|
const type = btn.dataset.type;
|
|
const data = type === 'widget' ? { widget_id: id } : { content_id: id };
|
|
try {
|
|
btn.disabled = true;
|
|
if (replaceItemId) {
|
|
btn.textContent = t('playlist.replacing');
|
|
// PUT supports a content/widget swap; the server nulls the opposite FK and
|
|
// preserves duration/schedule/zone. Close on success and re-render the list.
|
|
await api.updatePlaylistItem(playlistId, replaceItemId, data);
|
|
modal.remove();
|
|
const playlist = await api.getPlaylist(playlistId);
|
|
renderItems(playlist.items || []);
|
|
refreshAfterMutation();
|
|
showToast(t('playlist.toast.item_replaced'));
|
|
return;
|
|
}
|
|
btn.textContent = t('playlist.adding');
|
|
await api.addPlaylistItem(playlistId, data);
|
|
btn.textContent = t('playlist.added');
|
|
btn.classList.remove('btn-primary');
|
|
btn.classList.add('btn-secondary');
|
|
refreshAfterMutation();
|
|
} catch (err) {
|
|
btn.disabled = false;
|
|
btn.textContent = replaceItemId ? t('playlist.replace_btn') : t('playlist.add_btn');
|
|
showToast(err.message, 'error');
|
|
}
|
|
});
|
|
});
|
|
}
|
|
|
|
modal.querySelectorAll('.tab-btn').forEach(btn => {
|
|
btn.addEventListener('click', () => {
|
|
activeTab = btn.dataset.tab;
|
|
modal.querySelectorAll('.tab-btn').forEach(b => {
|
|
b.classList.toggle('btn-primary', b.dataset.tab === activeTab);
|
|
b.classList.toggle('btn-secondary', b.dataset.tab !== activeTab);
|
|
b.classList.toggle('active', b.dataset.tab === activeTab);
|
|
});
|
|
renderTab();
|
|
});
|
|
});
|
|
|
|
document.getElementById('addItemSearch').addEventListener('input', renderTab);
|
|
|
|
document.getElementById('closeAddModal').addEventListener('click', () => modal.remove());
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
|
|
|
renderTab();
|
|
}
|
|
|
|
// #74/#75: per-item schedule editor. Multiple blocks (days + time window + optional
|
|
// date range) OR together; an item with no blocks always plays. Client validation
|
|
// mirrors the server; saving marks the playlist DRAFT (must re-publish to reach devices).
|
|
function showScheduleModal(item) {
|
|
let blocks = (item.schedules || []).map(b => ({
|
|
days: Array.isArray(b.days) ? [...b.days] : [],
|
|
start: b.start || '00:00',
|
|
end: b.end || '24:00',
|
|
start_date: b.start_date || '',
|
|
end_date: b.end_date || ''
|
|
}));
|
|
|
|
const modal = document.createElement('div');
|
|
modal.style.cssText = 'position:fixed;inset:0;background:rgba(0,0,0,0.6);display:flex;align-items:center;justify-content:center;z-index:1000';
|
|
document.body.appendChild(modal);
|
|
|
|
function blockRow(b, idx) {
|
|
const eod = b.end === '24:00';
|
|
const dayLabels = t('itemsched.dow_short').split(',');
|
|
return `
|
|
<div style="border:1px solid var(--border);border-radius:var(--radius);padding:12px;margin-bottom:10px">
|
|
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
|
|
<strong style="font-size:13px">${t('itemsched.block', { n: idx + 1 })}</strong>
|
|
<button class="sched-remove" data-idx="${idx}" title="${t('itemsched.remove_block')}" style="color:var(--text-muted);background:none;border:none;cursor:pointer;font-size:14px">✕</button>
|
|
</div>
|
|
<div style="display:flex;gap:4px;flex-wrap:wrap;margin-bottom:10px">
|
|
${dayLabels.map((lbl, d) => `<button class="sched-day" data-idx="${idx}" data-day="${d}" style="padding:4px 9px;border-radius:6px;font-size:12px;cursor:pointer;border:1px solid var(--border);background:${b.days.includes(d) ? 'var(--accent)' : 'var(--bg-input)'};color:${b.days.includes(d) ? '#000' : 'var(--text-muted)'}">${lbl}</button>`).join('')}
|
|
</div>
|
|
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center">
|
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.from')} <input type="time" class="input sched-start" data-idx="${idx}" value="${esc(b.start)}" style="width:118px"></label>
|
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.to')} <input type="time" class="input sched-end" data-idx="${idx}" value="${esc(eod ? '00:00' : b.end)}" ${eod ? 'disabled' : ''} style="width:118px"></label>
|
|
<label style="font-size:12px;color:var(--text-muted)"><input type="checkbox" class="sched-eod" data-idx="${idx}" ${eod ? 'checked' : ''}> ${t('itemsched.end_of_day')}</label>
|
|
</div>
|
|
<div style="display:flex;gap:12px;flex-wrap:wrap;align-items:center;margin-top:10px">
|
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.starts')} <input type="date" class="input sched-sd" data-idx="${idx}" value="${esc(b.start_date)}" style="width:150px"></label>
|
|
<label style="font-size:12px;color:var(--text-muted)">${t('itemsched.ends')} <input type="date" class="input sched-ed" data-idx="${idx}" value="${esc(b.end_date)}" style="width:150px"></label>
|
|
<span style="font-size:11px;color:var(--text-muted)">${t('itemsched.dates_hint')}</span>
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
function render() {
|
|
modal.innerHTML = `
|
|
<div style="background:var(--bg-card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:24px;width:580px;max-width:94vw;max-height:88vh;overflow:auto">
|
|
<h3 style="margin:0 0 4px">${t('itemsched.title')}</h3>
|
|
<p style="font-size:12px;color:var(--text-muted);margin:0 0 10px">${esc(item.filename || item.widget_name || 'item')}</p>
|
|
<p style="font-size:12px;color:#7dd3fc;background:#0c2a3f;border-radius:6px;padding:8px 10px;margin:0 0 16px">${t('itemsched.hint')}</p>
|
|
<div>${blocks.length ? blocks.map(blockRow).join('') : `<p style="font-size:13px;color:var(--text-muted);margin:0 0 10px">${t('itemsched.none')}</p>`}</div>
|
|
<button class="btn btn-secondary btn-sm" id="schedAddBlock" style="margin-bottom:4px">${t('itemsched.add_block')}</button>
|
|
<div style="display:flex;justify-content:flex-end;gap:8px;margin-top:20px">
|
|
<button class="btn btn-secondary" id="schedCancel">${t('itemsched.cancel')}</button>
|
|
<button class="btn" id="schedSave" style="background:#f59e0b;color:#000;font-weight:600;border:none">${t('itemsched.save')}</button>
|
|
</div>
|
|
</div>`;
|
|
wire();
|
|
}
|
|
|
|
function wire() {
|
|
modal.querySelectorAll('.sched-day').forEach(btn => btn.addEventListener('click', () => {
|
|
const i = +btn.dataset.idx, d = +btn.dataset.day;
|
|
const set = new Set(blocks[i].days);
|
|
if (set.has(d)) set.delete(d); else set.add(d);
|
|
blocks[i].days = [...set];
|
|
render();
|
|
}));
|
|
modal.querySelectorAll('.sched-start').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start = el.value; }));
|
|
modal.querySelectorAll('.sched-end').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end = el.value; }));
|
|
modal.querySelectorAll('.sched-eod').forEach(el => el.addEventListener('change', () => {
|
|
blocks[+el.dataset.idx].end = el.checked ? '24:00' : '17:00';
|
|
render();
|
|
}));
|
|
modal.querySelectorAll('.sched-sd').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].start_date = el.value; }));
|
|
modal.querySelectorAll('.sched-ed').forEach(el => el.addEventListener('change', () => { blocks[+el.dataset.idx].end_date = el.value; }));
|
|
modal.querySelectorAll('.sched-remove').forEach(btn => btn.addEventListener('click', () => { blocks.splice(+btn.dataset.idx, 1); render(); }));
|
|
document.getElementById('schedAddBlock').addEventListener('click', () => {
|
|
blocks.push({ days: [0, 1, 2, 3, 4, 5, 6], start: '09:00', end: '17:00', start_date: '', end_date: '' });
|
|
render();
|
|
});
|
|
document.getElementById('schedCancel').addEventListener('click', () => modal.remove());
|
|
document.getElementById('schedSave').addEventListener('click', doSave);
|
|
}
|
|
|
|
async function doSave() {
|
|
const payload = blocks.map(b => ({
|
|
days: b.days, start: b.start, end: b.end,
|
|
start_date: b.start_date || null, end_date: b.end_date || null
|
|
}));
|
|
const err = validateScheduleBlocks(payload);
|
|
if (err) { showToast(err, 'error'); return; }
|
|
try {
|
|
const saved = await api.setItemSchedules(currentPlaylistId, item.id, payload);
|
|
item.schedules = saved;
|
|
modal.remove();
|
|
// Saving makes the playlist a DRAFT — surface the re-publish step explicitly.
|
|
showToast(payload.length ? t('itemsched.toast.saved') : t('itemsched.toast.cleared'));
|
|
const playlist = await api.getPlaylist(currentPlaylistId);
|
|
renderItems(playlist.items || []);
|
|
refreshAfterMutation();
|
|
} catch (e) {
|
|
showToast(e.message, 'error');
|
|
}
|
|
}
|
|
|
|
modal.addEventListener('click', (e) => { if (e.target === modal) modal.remove(); });
|
|
render();
|
|
}
|