mirror of
https://github.com/screentinker/screentinker.git
synced 2026-08-14 22:33:12 -06:00
Adding a 32s video gave it the flat 10s default, so it was cut off mid-play unless the operator looked up the runtime and typed it — per item, every time. The content row already carries the probed duration; it now becomes the default. The rule lives in one place (lib/item-duration.js) because the operator sees one product, not six insert paths: playlist add, assign-to-display, group assign, agency portal, content-only schedule, and the public API all share it. Only the playlist route defaulted before, and it stored the raw probe (31.7) which the Android player's optInt read silently truncated back to 31. Explicit values always win. Content with no trustworthy duration (image, widget, YouTube, remote URL, failed probe) keeps the 10s default, and a duration that is 0/negative/NaN or absurd (> 12h, i.e. a broken probe) falls back rather than reaching a device — a 0 makes the players schedule a 0ms advance, which self-loops and black-screens the TV. Dashboard: the add-item picker shows a clip's length, the assign-to-display modal pre-fills the duration field from the selected clip (never overwriting a value the operator typed), and onboarding stops hardcoding 10 on the first assignment. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
921 lines
48 KiB
JavaScript
921 lines
48 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); }
|
|
});
|
|
}
|
|
|
|
/*
|
|
* A small picture of where this playlist's content actually lands.
|
|
*
|
|
* A playlist has no intrinsic layout — the server derives one from the items' own zone bindings
|
|
* (#104) — so the page could previously show an item tagged "Bottom Ticker" with no indication
|
|
* that the ticker is a thin strip along the bottom. People assigned content to zones by name and
|
|
* found out where it went by looking at a screen.
|
|
*
|
|
* Drawn from the zone percentages, so it is correct for any layout including portrait ones without
|
|
* a stored thumbnail. Zones with no items are dimmed: an empty zone on a real panel shows its
|
|
* background colour, and that is worth seeing BEFORE publishing rather than after.
|
|
*/
|
|
function layoutMockup(playlist) {
|
|
const layout = playlist && playlist.layout;
|
|
const items = (playlist && playlist.items) || [];
|
|
|
|
// No layout means fullscreen — every item shares one frame. Drawing a single empty box would
|
|
// imply a choice was made; say it in words instead.
|
|
if (!layout || !Array.isArray(layout.zones) || layout.zones.length === 0) {
|
|
return `<div style="font-size:12px;color:var(--text-muted);margin-bottom:12px">${t('playlist.layout_fullscreen')}</div>`;
|
|
}
|
|
|
|
const counts = {};
|
|
for (const it of items) if (it.zone_id) counts[it.zone_id] = (counts[it.zone_id] || 0) + 1;
|
|
|
|
const w = Number(layout.width) || 1920;
|
|
const h = Number(layout.height) || 1080;
|
|
const portrait = h > w;
|
|
// Fixed short edge, long edge derived — a portrait mockup must not be as wide as a landscape one
|
|
// or it dominates the page.
|
|
const boxW = portrait ? 90 : 200;
|
|
const boxH = Math.round(boxW * (h / w));
|
|
|
|
const zones = layout.zones.map((z) => {
|
|
const n = counts[z.id] || 0;
|
|
const filled = n > 0;
|
|
return `<div title="${esc(z.name)}${filled ? ` — ${n}` : ''}" style="
|
|
position:absolute;
|
|
left:${z.x_percent}%; top:${z.y_percent}%;
|
|
width:${z.width_percent}%; height:${z.height_percent}%;
|
|
box-sizing:border-box;
|
|
border:1px solid ${filled ? 'var(--accent)' : 'var(--border)'};
|
|
background:${filled ? 'color-mix(in srgb, var(--accent) 18%, transparent)' : 'transparent'};
|
|
display:flex;align-items:center;justify-content:center;
|
|
font-size:9px;line-height:1;color:var(--text-muted);overflow:hidden;
|
|
">${filled ? n : ''}</div>`;
|
|
}).join('');
|
|
|
|
return `
|
|
<div style="display:flex;align-items:center;gap:12px;margin-bottom:12px">
|
|
<div style="position:relative;width:${boxW}px;height:${boxH}px;background:var(--bg-primary);border:1px solid var(--border);border-radius:4px;flex:none">
|
|
${zones}
|
|
</div>
|
|
<div style="font-size:12px;color:var(--text-muted)">
|
|
<div>${esc(layout.name || '')} · ${w}×${h}${portrait ? ' (portrait)' : ''}</div>
|
|
<div>${tn('playlist.zones_count', layout.zones.length)}</div>
|
|
${layout._preview_ambiguous ? `<div style="color:var(--warning)">${t('playlist.layout_ambiguous')}</div>` : ''}
|
|
</div>
|
|
</div>`;
|
|
}
|
|
|
|
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>
|
|
|
|
${layoutMockup(playlist)}
|
|
|
|
<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');
|
|
// #237: the server gives a video item the clip's own length instead of the 10s default.
|
|
// Show that length here so the duration the item lands with is something the operator
|
|
// saw coming, rather than a number that appears in the list after the fact.
|
|
const clipSec = !isWidget && Number(item.duration_sec) > 0 ? Math.ceil(item.duration_sec) : 0;
|
|
const clip = clipSec ? ` · ${Math.floor(clipSec / 60)}:${String(clipSec % 60).padStart(2, '0')}` : '';
|
|
const sub = isWidget ? (item.widget_type || t('playlist.item_widget')) : ((item.mime_type || '') + clip);
|
|
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();
|
|
}
|