screentinker/frontend/js/views/playlists.js
ScreenTinker 52ab04204a Preview a rotated display the way people see it, not the way its framebuffer is
#238: the dashboard preview of a 90/270 display was sideways while the panel on the
wall was right — the split that makes a preview useless, because a designer checking
portrait content can no longer tell a real fault from an artefact of the tool.

A portrait panel is a landscape framebuffer that the player rotates content INSIDE
(+90), hung turned the other way (-90); the two cancel and the viewer sees upright
portrait. The dashboard modelled only the first half. It iframed the player into a
box it had already given the finished 9/16 shape, so the player rotated a second time
inside a box that was pretending to be the finished picture, and nothing anywhere
stood in for the mount. Screenshots had the opposite half missing: they are the raw
framebuffer, shown untouched, so every portrait screen looked wrong on the cards and
in Now Playing too.

So each surface now has a stage (the panel's face) and a frame (its framebuffer),
with the frame turned by the INVERSE of the player's angle. Turning it the same way
is the tempting mistake and the worst kind of wrong: 90+90 lands upside-down, which
reads as nearly-right. The dimension swap is not cosmetic either — composing into the
real framebuffer shape is what makes the player lay content out in the same portrait
box the panel uses; hand it a portrait viewport instead and every zone and object-fit
decision is computed for a canvas no panel has.

The geometry is the players' own rule (server/lib/orientation-style.js), served to the
dashboard rather than re-derived, since a second copy of a rotation rule is exactly how
the two came to disagree. Covers the device preview modal, the playlist preview's
portrait toggle (same fault), Now Playing and the device cards. The Remote canvas stays
raw on purpose: taps are sent as fractions of it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Uaeo9MvzKoyXuN6ZsbhtkL
2026-08-06 09:38:31 -05:00

926 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';
import { frameDeviceOutput, displayAspectRatio } from '../lib/device-frame.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.
// #238: Portrait here had the same fault as the device preview — the iframe was given the
// as-displayed 9/16 shape AND the player rotated inside it, so the portrait toggle showed sideways
// content. The stage is the panel's face; the iframe is its landscape framebuffer, turned back by
// the stand-in for the wall mount.
function showPlaylistPreview(playlist) {
let orientation = 'landscape';
const aspect = () => displayAspectRatio(orientation);
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">
<div id="pvpStage" style="height:78vh;max-width:92vw;aspect-ratio:${aspect()};background:#000">
<iframe id="pvpFrame" style="border:0;background:#000" src="${frameSrc()}"></iframe>
</div>
</div>
</div>`;
document.body.appendChild(overlay);
const stage = overlay.querySelector('#pvpStage');
const frame = overlay.querySelector('#pvpFrame');
frameDeviceOutput(stage, frame, orientation);
const btnL = overlay.querySelector('#pvpLandscape');
const btnP = overlay.querySelector('#pvpPortrait');
const setOrientation = (o) => {
orientation = o;
stage.style.aspectRatio = aspect();
frameDeviceOutput(stage, frame, orientation);
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 || '')} &middot; ${w}&times;${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')}">&larr;</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');
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();
}