// The library's edit-song modal: open, validate, save, delete. // // Interface width ZERO — nothing in app.js calls into this cluster; app.js only needs the four // names on the window contract so the markup's onclick= handlers resolve. That is what makes it // the cleanest slice left, and it only became clean because the LIBRARY came out first (#896): // every dependency this modal has is now a module. // // It reads six bindings out of ./library.js (loadLibrary, loadFavorites, loadTreeView, // _removeLibCardsForFilename, libView, _lastLibSelected) and never writes one — checked, which // matters: an imported binding is READ-ONLY, so a single write would have forced a setter or a // container. Every use is a read, so plain imports suffice. // // Acyclic: edit-modal -> { dom, library-state, library }, and library imports none of them back. import { _confirmDialog, _escAttr, _trapFocusInModal } from './dom.js'; import { L } from './library-state.js'; import { _lastLibSelected, _removeLibCardsForFilename, libView, loadFavorites, loadLibrary, loadTreeView, } from './library.js'; // ── Edit metadata modal ───────────────────────────────────────────────── export function openEditModal(songData, openerEl) { const artUrl = `/api/song/${encodeURIComponent(songData.f)}/art?t=${Date.now()}`; const modal = document.createElement('div'); modal.id = 'edit-modal'; modal.className = 'feedBack-modal fixed inset-0 z-[200] flex items-center justify-center bg-black/70 backdrop-blur-sm'; // role=dialog: assistive tech announces it as a modal; also lets // the global keyboard listener's `_isInsideInteractiveControl` // bail when typing inside the modal so Library shortcuts don't // hijack keys from the edit form. modal.setAttribute('role', 'dialog'); modal.setAttribute('aria-modal', 'true'); modal.setAttribute('aria-label', 'Edit song metadata'); // Record the element that triggered the modal so Esc / Cancel can // return focus to the exact entry the user was on, even if // _lastLibSelected changes before the modal closes. // Prefer the explicitly-passed openerEl (from the edit-btn click // handler, which has the exact [data-play] parent) over // _lastLibSelected, which may not have been updated when the // click's stopPropagation() prevented the card-click handler. const _emActive = document.querySelector('.screen.active'); const _emLast = (_lastLibSelected && document.body.contains(_lastLibSelected) && _emActive && _emActive.contains(_lastLibSelected)) ? _lastLibSelected : null; modal._opener = (openerEl && document.body.contains(openerEl)) ? openerEl : _emLast; modal.innerHTML = `

Edit Song

Change

Click image to change album art

`; document.body.appendChild(modal); // Move focus into the dialog's first text input so background // shortcuts (and arrow nav) can't fire on the underlying library // entry while the edit form is open. Title is the natural primary // field — most edits are correcting spelling there. Caret-end // selection so the user can keep typing rather than overtype the // current value. const titleInput = document.getElementById('edit-title'); if (titleInput) { titleInput.focus({ preventScroll: true }); try { const len = titleInput.value.length; titleInput.setSelectionRange(len, len); } catch { /* some browsers reject selection on certain input types */ } } // Trap Tab / Shift+Tab inside the modal so focus can't escape to // the library content underneath while the edit form is open. _trapFocusInModal(modal); // Click on art triggers file input document.getElementById('edit-art-wrapper').addEventListener('click', () => { document.getElementById('edit-art-file').click(); }); // Save — wired in JS (not an inline onclick) so the filename never has to // survive embedding in a single-quoted attribute string. encodeURIComponent // does NOT escape `'`, so a filename like `Bob's Song.sloppak` used to break // the inline `saveEditModal('…')` handler and silently fail the save. The // raw filename lives in the closure; encode it here for saveEditModal. const saveBtn = modal.querySelector('[data-edit-save]'); if (saveBtn) { saveBtn.addEventListener('click', () => saveEditModal(encodeURIComponent(songData.f))); } const deleteBtn = modal.querySelector('[data-delete-filename]'); if (deleteBtn) { deleteBtn.addEventListener('click', () => { deleteSongFromModal(deleteBtn.dataset.deleteFilename); }); } // Close on backdrop click or Cancel button; restore focus to opener. // Backdrop dismissal requires the gesture's mousedown to have STARTED on // the backdrop — not just the click/mouseup to land there. Otherwise a // click-drag that begins inside a field (e.g. selecting text) and is // released past the modal edge resolves its `click` target to the backdrop // and silently discards the edit. Cancel / ✕ (data-edit-close) always close. let _downOnBackdrop = false; modal.addEventListener('mousedown', (e) => { _downOnBackdrop = (e.target === modal); }); modal.addEventListener('click', (e) => { if (!_editModalShouldClose(e.target, modal, _downOnBackdrop)) return; const opener = modal._opener; modal.remove(); const focusTarget = (opener && document.body.contains(opener)) ? opener : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); if (focusTarget) focusTarget.focus({ preventScroll: true }); }); } // Whether a click on the edit-metadata modal should dismiss it. The Cancel / ✕ // control (data-edit-close) always dismisses. A backdrop dismissal needs BOTH // the click target to be the backdrop element itself AND the gesture to have // started there (downOnBackdrop) — so a click-drag begun inside a field and // released on the backdrop does not discard the form. Pure + top-level so it's // unit-testable in isolation. export function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) { if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true; return clickTarget === modalEl && downOnBackdrop === true; } export async function saveEditModal(encodedFilename) { const filename = decodeURIComponent(encodedFilename); // Save metadata await fetch(`/api/song/${encodeURIComponent(filename)}/meta`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ title: document.getElementById('edit-title').value.trim(), artist: document.getElementById('edit-artist').value.trim(), album: document.getElementById('edit-album').value.trim(), // Year is normalised server-side (non-numeric/empty → ""), so a // blank or cleared field round-trips safely. year: document.getElementById('edit-year').value.trim(), }), }); // Upload art if changed const fileInput = document.getElementById('edit-art-file'); if (fileInput.files && fileInput.files[0]) { const reader = new FileReader(); reader.onload = async (e) => { await fetch(`/api/song/${encodeURIComponent(filename)}/art/upload`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ image: e.target.result }), }); }; reader.readAsDataURL(fileInput.files[0]); } const modal = document.getElementById('edit-modal'); const opener = modal ? modal._opener : null; if (modal) modal.remove(); // Restore focus to the entry the modal was opened from so subsequent // keyboard navigation resumes correctly (same as Esc / Cancel paths). const focusTarget = (opener && document.body.contains(opener)) ? opener : (_lastLibSelected && document.body.contains(_lastLibSelected) ? _lastLibSelected : null); if (focusTarget) focusTarget.focus({ preventScroll: true }); // Refresh current view const activeScreen = document.querySelector('.screen.active'); if (activeScreen?.id === 'favorites') loadFavorites(); else loadLibrary(); } export async function deleteSongFromModal(filename) { const title = (document.getElementById('edit-title')?.value || filename).trim(); const ok = await _confirmDialog({ title: 'Remove from library?', body: `

Remove ${_escAttr(title)} from your library?

This permanently deletes the file from disk. This cannot be undone.

`, confirmText: 'Remove', cancelText: 'Cancel', danger: true, }); if (!ok) return; let resp; try { resp = await fetch(`/api/song/${encodeURIComponent(filename)}`, { method: 'DELETE' }); } catch (e) { alert(`Delete failed: ${e.message}`); return; } if (!resp.ok) { let msg = resp.statusText; try { msg = (await resp.json()).error || msg; } catch (_) {} alert(`Delete failed: ${msg}`); return; } const modal = document.getElementById('edit-modal'); if (modal) modal.remove(); L.treeStats = null; L.favTreeStats = null; L.tuningNames = null; // Remove the deleted song's card from any currently-rendered grid/tree // so the user sees it disappear without waiting for a refetch. A full // loadLibrary() here would re-call loadGridPage(currentPage), which // uses 'append' mode when currentPage > 0 and re-appends the same // (now-shortened) page on top of what's already rendered — leaving // the deleted card visible. Direct DOM removal also preserves scroll // position, which a refetch from page 0 would lose. _removeLibCardsForFilename(filename); // Tree views group by artist with song counts; a single card removal // leaves stale counts, so refresh the tree for whichever screen we're // looking at (each tree-view renderer replaces innerHTML cleanly). const activeScreen = document.querySelector('.screen.active'); if (activeScreen?.id === 'favorites') { // loadFavorites() routes to either loadFavGridPage (always // 'replace') or loadFavTreeView — both safe for a single delete. loadFavorites(); } else if (libView === 'tree') { loadTreeView(); } // Main library grid view: DOM removal above is sufficient. }