diff --git a/static/v3/songs.js b/static/v3/songs.js index 5b64135..2209c35 100644 --- a/static/v3/songs.js +++ b/static/v3/songs.js @@ -815,33 +815,122 @@ finishBatch(); } - // Prompt for a target playlist (pick a listed number, or type a new name to - // create it) and add the given song filenames to it. Shared by the - // select-mode batch bar and the per-card ⋮ menu's single-song add. Returns - // the playlist id (or null if cancelled). + // Modern "Add to playlist" picker - replaces the old run-on numbered prompt + // ("1. Foo 2. Bar ...", which didn't scale past a couple of playlists). Shows a + // checkbox list of playlists with membership PRE-CHECK (a song already in a + // playlist shows checked; a multi-song selection shows the indeterminate box + // when only some are in), an inline "New playlist" row, and a search box once + // the list is long. Toggling a row adds/removes the given songs via the + // existing REST; only rows the user actually TOUCHED are changed. Resolves + // true if any change was applied, else null (cancelled / no-op). Shared by the + // per-card more-menu and the select-mode batch bar. + function openPlaylistPicker(fns) { + return new Promise((resolve) => { (async () => { + const lists = ((await jget('/api/playlists')) || []).filter((p) => !p.system_key); + // Pre-check membership: fetch each playlist's songs once (playlists are + // few, and this is a one-off on open, not a hot path). ALL selected in + // -> checked; SOME -> indeterminate. + const counts = await Promise.all(lists.map(async (p) => { + const pl = await jget('/api/playlists/' + p.id); + const has = new Set(((pl && pl.songs) || []).map((s) => s.filename)); + return fns.reduce((n, fn) => n + (has.has(fn) ? 1 : 0), 0); + })); + const rows = lists.map((p, i) => ({ + id: p.id, name: p.name, count: p.count || 0, + initial: counts[i] === fns.length ? 'all' : (counts[i] > 0 ? 'some' : 'none'), + checked: counts[i] === fns.length, touched: false, + })); + + const overlay = document.createElement('div'); + overlay.className = 'fixed inset-0 z-[200] bg-black/60 backdrop-blur-sm flex items-center justify-center p-4'; + let query = ''; + const onKey = (e) => { if (e.key === 'Escape') { e.preventDefault(); done(null); } }; + function done(applied) { overlay.remove(); document.removeEventListener('keydown', onKey); resolve(applied || null); } + document.addEventListener('keydown', onKey); + overlay.addEventListener('click', (e) => { if (e.target === overlay) done(null); }); + + const boxFor = (r) => r.touched ? (r.checked ? '☑' : '☐') + : (r.initial === 'all' ? '☑' : r.initial === 'some' ? '▣' : '☐'); + function rowHtml(r) { + return ''; + } + function listHtml() { + const q = query.trim().toLowerCase(); + const shown = q ? rows.filter((r) => r.name.toLowerCase().includes(q)) : rows; + if (!shown.length) return '
' + (rows.length ? 'No matches.' : 'No playlists yet - create one above.') + '
'; + return shown.map(rowHtml).join(''); + } + function bindRows() { + overlay.querySelectorAll('[data-pl]').forEach((b) => b.addEventListener('click', () => { + const r = rows.find((x) => String(x.id) === b.getAttribute('data-pl')); + if (!r) return; + r.touched = true; r.checked = !r.checked; + repaintList(); + })); + } + function repaintList() { const el = overlay.querySelector('[data-list]'); if (el) { el.innerHTML = listHtml(); bindRows(); } } + async function createNew() { + const inp = overlay.querySelector('[data-new]'); + const name = ((inp && inp.value) || '').trim(); + if (!name) return; + const created = await jsend('POST', '/api/playlists', { name }); + if (created && created.id) { + rows.unshift({ id: created.id, name: created.name || name, count: 0, initial: 'none', checked: true, touched: true }); + query = ''; paint(); + overlay.querySelector('[data-new]')?.focus(); + } + } + async function apply() { + // Act only where the final state differs from what's already stored. + const acts = rows.filter((r) => r.touched && ((r.checked && r.initial !== 'all') || (!r.checked && r.initial !== 'none'))); + for (const r of acts) { + for (const fn of fns) { + try { + if (r.checked) await fetch('/api/playlists/' + r.id + '/songs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ filename: fn }) }); + else await fetch('/api/playlists/' + r.id + '/songs/' + encodeURIComponent(fn), { method: 'DELETE' }); + } catch (e) { /* */ } + } + } + if (window.v3Playlists) { try { window.v3Playlists.refresh(); } catch (e) { /* */ } } + if (acts.length && window.fbNotify) { + try { window.fbNotify.show({ title: 'Playlists updated', message: 'Updated ' + acts.length + ' playlist' + (acts.length === 1 ? '' : 's'), icon: '\U0001f3b5' }); } catch (e) { /* */ } + } + done(acts.length ? true : null); + } + function paint() { + overlay.innerHTML = + '