// 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.
}