diff --git a/static/app.js b/static/app.js index 9350924..5b9f6e7 100644 --- a/static/app.js +++ b/static/app.js @@ -190,6 +190,12 @@ import { toggleFavorite, toggleLibFilters, } from './js/library.js'; +import { + _editModalShouldClose, + deleteSongFromModal, + openEditModal, + saveEditModal, +} from './js/edit-modal.js'; // The playback transport. These used to BE app.js — they are imported back now, and the // four modules that reached for them through the host seam import them directly instead. import { @@ -3879,148 +3885,6 @@ registerShortcut({ handler: () => _adjustSongVolume(-1) }); -// ── Edit metadata modal ───────────────────────────────────────────────── -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. -function _editModalShouldClose(clickTarget, modalEl, downOnBackdrop) { - if (clickTarget && clickTarget.closest && clickTarget.closest('[data-edit-close]')) return true; - return clickTarget === modalEl && downOnBackdrop === true; -} - function previewEditArt(input) { if (!input.files || !input.files[0]) return; const reader = new FileReader(); @@ -4030,104 +3894,6 @@ function previewEditArt(input) { reader.readAsDataURL(input.files[0]); } -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(); -} - -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. -} - async function syncLibrarySong(providerId, songId, options = {}) { const opts = options && typeof options === 'object' ? options : {}; const { playWhenReady = false } = opts; diff --git a/static/js/edit-modal.js b/static/js/edit-modal.js new file mode 100644 index 0000000..fc8197c --- /dev/null +++ b/static/js/edit-modal.js @@ -0,0 +1,258 @@ +// 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. +} diff --git a/tests/js/edit_metadata_modal.test.js b/tests/js/edit_metadata_modal.test.js index 178013e..9d6dd26 100644 --- a/tests/js/edit_metadata_modal.test.js +++ b/tests/js/edit_metadata_modal.test.js @@ -1,4 +1,4 @@ -// Regression guards for two Edit-Metadata modal fixes (static/app.js): +// Regression guards for two Edit-Metadata modal fixes (static/js/edit-modal.js): // // 1. Year is editable — the modal renders an `edit-year` field and // saveEditModal() includes `year` in the POST /api/song//meta body. @@ -19,8 +19,11 @@ const path = require('node:path'); const vm = require('node:vm'); const { extractFunction } = require('./test_utils'); -const APP_JS = path.join(__dirname, '..', '..', 'static', 'app.js'); -const readApp = () => fs.readFileSync(APP_JS, 'utf8'); +// R3d: the edit modal was carved out of app.js into its own module. Bodies unchanged — only +// the file moved. (It could go cleanly because the LIBRARY came out first: every dependency the +// modal has is a module now, and it reads six library bindings without writing any.) +const EDIT_MODAL_JS = path.join(__dirname, '..', '..', 'static', 'js', 'edit-modal.js'); +const readApp = () => fs.readFileSync(EDIT_MODAL_JS, 'utf8'); function loadFn(signature, sandbox, exportAs) { const fnSrc = extractFunction(readApp(), signature);