import { api } from '../api.js';
import { showToast } from '../components/toast.js';
import { esc } 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 ' ';
if (item.mime_type && item.mime_type.startsWith('video/')) return ' ';
return ' ';
}
// #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 = `
`;
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 = `
${t('playlist.empty_title')}
${t('playlist.empty_desc')}
`;
return;
}
const filtered = showAutoGenerated ? playlists : playlists.filter(p => !p.is_auto_generated);
if (!filtered.length) {
grid.innerHTML = `
${playlists.length ? t('playlist.all_auto_generated') : ''}
`;
return;
}
grid.innerHTML = filtered.map(p => `
${esc(p.name)}
${p.is_auto_generated ? `
${t('playlist.tag_auto')} ` : ''}
${p.status === 'draft' ? `
${t('playlist.tag_draft')} ` : ''}
${tn('playlist.item_count', p.item_count)}
${p.description ? `${esc(p.description)}
` : ''}
${t('playlist.created_at', { date: formatDate(p.created_at) })}
${p.display_count ? `${tn('playlist.display_count', p.display_count)} ` : ''}
`).join('');
} catch (err) {
grid.innerHTML = `${t('playlist.load_failed', { error: esc(err.message) })}
`;
}
}
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 = `
`;
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 = `
${t('common.loading')}
`;
try {
const playlist = await api.getPlaylist(playlistId);
renderDetailContent(container, playlist);
} catch (err) {
container.innerHTML = `
`;
}
}
// #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 = `
${t('widget.preview')} — ${esc(playlist.name)}
${t('device.form.orientation.landscape')}
${t('device.form.orientation.portrait')}
${t('widget.close')}
`;
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 ? `
${t('playlist.draft.banner_title')}
${hasPublished ? t('playlist.draft.devices_showing_published') : t('playlist.draft.never_published')}
${hasPublished ? `${t('playlist.draft.discard_changes')} ` : ''}
${t('playlist.draft.publish')}
` : ''}
`;
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 = `
${t('playlist.items_empty')}
${t('playlist.items_empty_hint')}
`;
return;
}
itemsEl.innerHTML = items.map((item, i) => `
${i + 1}
${item.thumbnail_path
? `
`
: `
${getTypeIcon(item)}
`
}
${esc(item.filename || item.widget_name || t('common.unknown'))}
${item.widget_id ? t('playlist.item_widget') : esc(item.mime_type || t('playlist.unknown_type'))}
${item.schedules && item.schedules.length ? `🕐 ${esc(scheduleSummary(item.schedules))} ` : ''}
${t('playlist.duration')}
${t('playlist.sec')}
`).join('');
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 = `${t('playlist.add_desc_placeholder')} `;
}
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 = `
${replaceItemId ? t('playlist.replace_modal_title') : t('playlist.add_modal_title')}
${t('playlist.tab_content')}
${t('playlist.tab_widgets')}
${t('playlist.close')}
`;
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 = `${t('playlist.load_failed', { error: esc(err.message) })}
`;
}
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 = `${activeTab === 'content' ? t('playlist.no_content_found') : t('playlist.no_widgets_found')}
`;
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 `
${thumb ? `
` : '
'}
${replaceItemId ? t('playlist.replace_btn') : t('playlist.add_btn')}
`;
}).join('');
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 `
`;
}
function render() {
modal.innerHTML = `
${t('itemsched.title')}
${esc(item.filename || item.widget_name || 'item')}
${t('itemsched.hint')}
${blocks.length ? blocks.map(blockRow).join('') : `
${t('itemsched.none')}
`}
${t('itemsched.add_block')}
${t('itemsched.cancel')}
${t('itemsched.save')}
`;
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();
}