Initial Commit

This commit is contained in:
Ian Keane 2026-06-09 15:04:27 -04:00
commit eb130ffcfd
6 changed files with 1265 additions and 0 deletions

725
app.js Normal file
View file

@ -0,0 +1,725 @@
/*
Repertory frontend app
Plain JS, no build step, no framework.
*/
// ── State ────────────────────────────────────────────────────
let allTunes = [];
let allSources = [];
let allMusicians = [];
let allInstruments = [];
let allTunings = [];
let sortCol = 'name';
let sortDir = 'asc';
// editing state
let editingTuneId = null; // null = add mode
let editingEntryId = null; // null = no existing instrument entry
// notes modal state
let notesTuneId = null;
let notesEntryId = null;
let unlocked = false;
// ── DOM refs ─────────────────────────────────────────────────
const $ = id => document.getElementById(id);
const $$ = sel => document.querySelectorAll(sel);
const statusEl = $('status');
const tbody = $('tune-tbody');
const emptyMsg = $('empty-msg');
const tuneModal = $('tune-modal');
const notesModal = $('notes-modal');
const tuneForm = $('tune-form');
const refsList = $('refs-list');
const btnUnlock = $('btn-unlock');
const btnAddTune = $('btn-add-tune');
const sourceInput = $('source-input');
const sourceDatalist = $('source-list');
const musicianDatalist = $('musician-list');
const instrumentSelect = $('instrument-select');
const tuningDatalist = $('tuning-list');
const btnDeleteTune = $('btn-delete-tune');
const btnEditNotes = $('btn-edit-notes');
// ── Config ───────────────────────────────────────────────────
const _cfg = window.REPERTORY_CONFIG || {};
function getApiKey() {
return _cfg.apiKey || sessionStorage.getItem('apiKey') || '';
}
// ── Unlock ───────────────────────────────────────────────────
if (getApiKey()) setUnlocked(true);
btnUnlock.addEventListener('click', () => {
const key = prompt('API key:');
if (!key) return;
sessionStorage.setItem('apiKey', key);
if (!_cfg.apiUrl) {
const url = prompt('API URL (e.g. https://your-server:5000):');
if (url) sessionStorage.setItem('apiUrl', url.replace(/\/$/, ''));
}
setUnlocked(true);
});
function setUnlocked(val) {
unlocked = val;
btnUnlock.hidden = val;
btnAddTune.hidden = !val;
// Show/hide edit column header and edit buttons
const editHeader = document.querySelector('.edit-col');
if (editHeader) editHeader.hidden = !val;
$$('.btn-edit-row').forEach(b => { b.hidden = !val; });
$$('.note-edit-controls').forEach(el => { el.hidden = !val; });
$('btn-add-tune-note').hidden = !val;
$('btn-add-inst-note').hidden = !val;
}
// ── API helpers ───────────────────────────────────────────────
function resolvedApiUrl() {
return (_cfg.apiUrl || sessionStorage.getItem('apiUrl') || '').replace(/\/$/, '');
}
async function apiFetch(path, options = {}) {
const url = resolvedApiUrl() + path;
const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) };
if (options.method && options.method !== 'GET') {
headers['X-API-Key'] = getApiKey();
}
const res = await fetch(url, { ...options, headers });
if (!res.ok) {
const text = await res.text();
throw new Error(`${res.status} ${res.statusText}: ${text}`);
}
return res.json();
}
function setStatus(msg, isError = false) {
statusEl.textContent = msg;
statusEl.style.color = isError ? '#eb5757' : '#6fcf97';
}
// ── Load ──────────────────────────────────────────────────────
async function loadAll() {
setStatus('Loading…');
try {
[allTunes, allSources, allMusicians, allInstruments, allTunings] = await Promise.all([
apiFetch('/tunes/'),
apiFetch('/sources/'),
apiFetch('/musicians/'),
apiFetch('/instruments'),
apiFetch('/tunings/'),
]);
populateFilterOptions();
populateDatalist(sourceDatalist, allSources);
populateDatalist(musicianDatalist, allMusicians);
populateDatalist(tuningDatalist, allTunings);
populateInstrumentSelect();
renderTable();
setStatus(`${allTunes.length} tunes loaded`);
} catch (e) {
setStatus(e.message, true);
}
}
function populateDatalist(datalist, items) {
datalist.innerHTML = '';
items.forEach(item => {
const opt = document.createElement('option');
opt.value = item.name;
datalist.appendChild(opt);
});
}
function populateInstrumentSelect(selectedId = null) {
instrumentSelect.innerHTML = '<option value="">— none —</option>';
allInstruments.forEach(inst => {
const opt = document.createElement('option');
opt.value = inst.id;
opt.textContent = inst.name;
if (selectedId && inst.id === selectedId) opt.selected = true;
instrumentSelect.appendChild(opt);
});
}
function populateFilterOptions() {
// Nothing to populate in DOM — popover dropdowns are built on demand
// from allSources, allInstruments, allTunings in buildDropdownOptions()
}
// ── Quick-add ─────────────────────────────────────────────────
async function quickAdd(endpoint, promptText, list, datalist, onDone) {
const name = prompt(promptText);
if (!name) return null;
try {
const item = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ name }) });
list.push(item);
list.sort((a, b) => a.name.localeCompare(b.name));
if (datalist) populateDatalist(datalist, list);
if (onDone) onDone(item);
return item;
} catch (e) {
setStatus(e.message, true);
return null;
}
}
$('btn-quick-add-source').addEventListener('click', async () => {
await quickAdd('/sources/', 'New source name:', allSources, sourceDatalist, item => {
sourceInput.value = item.name;
populateFilterOptions();
});
});
$('btn-quick-add-musician').addEventListener('click', async () => {
await quickAdd('/musicians/', 'New musician name:', allMusicians, musicianDatalist, item => {
tuneForm.elements.inst_learned_from.value = item.name;
});
});
$('btn-quick-add-tuning').addEventListener('click', async () => {
await quickAdd('/tunings/', 'New tuning name:', allTunings, tuningDatalist, item => {
tuneForm.elements.inst_tuning.value = item.name;
populateFilterOptions();
});
});
// ── Resolve name → id ─────────────────────────────────────────
function resolveSourceId(name) {
if (!name) return null;
return allSources.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null;
}
function resolveMusicianId(name) {
if (!name) return null;
return allMusicians.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null;
}
function resolveTuningId(name) {
if (!name) return null;
return allTunings.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null;
}
// ── Rows: one per instrument entry ────────────────────────────
// For tunes with no instrument entries, emit one row with empty instrument cols
function buildRows(tunes) {
const rows = [];
for (const tune of tunes) {
if (tune.instruments.length === 0) {
rows.push({ tune, entry: null });
} else {
for (const entry of tune.instruments) {
rows.push({ tune, entry });
}
}
}
return rows;
}
// ── Filters (popover per column) ─────────────────────────────
const filters = {
key: null,
source: null,
modal: null,
instrument: null,
tuning: null,
callable: null,
review: null,
to_learn: null,
difficulty: null,
};
const popover = $('filter-popover');
const popoverInner = $('filter-popover-inner');
let activeFilterKey = null;
const BOOL_FILTERS = new Set(['modal', 'callable', 'review', 'to_learn']);
const DROPDOWN_FILTERS = new Set(['source', 'instrument', 'tuning', 'difficulty']);
function openFilterPopover(key, thEl) {
if (activeFilterKey === key) { closeFilterPopover(); return; }
activeFilterKey = key;
popoverInner.innerHTML = '';
if (BOOL_FILTERS.has(key)) {
const label = document.createElement('label');
label.className = 'popover-checkbox-row';
const cb = document.createElement('input');
cb.type = 'checkbox';
if (filters[key] === true) { cb.checked = true; cb.indeterminate = false; }
else if (filters[key] === false) { cb.checked = false; cb.indeterminate = true; }
else { cb.checked = false; cb.indeterminate = false; }
const names = { modal: 'Modal', callable: 'Callable', review: 'Needs review', to_learn: 'To learn' };
label.appendChild(cb);
label.appendChild(document.createTextNode(names[key] ?? key));
popoverInner.appendChild(label);
cb.addEventListener('change', () => {
if (filters[key] === null) { filters[key] = true; cb.checked = true; cb.indeterminate = false; }
else if (filters[key] === true) { filters[key] = false; cb.checked = false; cb.indeterminate = true; }
else { filters[key] = null; cb.checked = false; cb.indeterminate = false; }
updateFilterIndicators(); renderTable();
});
} else if (DROPDOWN_FILTERS.has(key)) {
const sel = document.createElement('select');
buildDropdownOptions(key).forEach(([val, lbl]) => {
const opt = document.createElement('option');
opt.value = val; opt.textContent = lbl;
if (val === (filters[key] ?? '')) opt.selected = true;
sel.appendChild(opt);
});
popoverInner.appendChild(sel);
sel.addEventListener('change', () => {
filters[key] = sel.value || null;
updateFilterIndicators(); renderTable();
});
} else {
// key — plain text
const inp = document.createElement('input');
inp.type = 'text'; inp.placeholder = 'Filter…'; inp.value = filters[key] ?? '';
popoverInner.appendChild(inp);
inp.addEventListener('input', () => {
filters[key] = inp.value || null;
updateFilterIndicators(); renderTable();
});
requestAnimationFrame(() => inp.focus());
}
const clr = document.createElement('button');
clr.className = 'popover-clear'; clr.textContent = 'Clear';
clr.addEventListener('click', () => {
filters[key] = null; updateFilterIndicators(); renderTable(); closeFilterPopover();
});
popoverInner.appendChild(clr);
const rect = thEl.getBoundingClientRect();
popover.hidden = false;
popover.style.top = `${rect.bottom + 4}px`;
popover.style.left = `${Math.min(rect.left, window.innerWidth - 200)}px`;
}
function closeFilterPopover() {
popover.hidden = true;
activeFilterKey = null;
}
function buildDropdownOptions(key) {
const none = [['', '— all —']];
if (key === 'source') return [...none, ...allSources.map(s => [String(s.id), s.name])];
if (key === 'instrument') return [...none, ...allInstruments.map(i => [String(i.id), i.name])];
if (key === 'tuning') return [...none, ...allTunings.map(t => [String(t.id), t.name])];
if (key === 'difficulty') return [...none, ['easy', 'Easy'], ['hard', 'Hard']];
return none;
}
function updateFilterIndicators() {
$$('th[data-filter]').forEach(th => {
th.classList.toggle('filter-active', filters[th.dataset.filter] !== null);
});
const anyActive = Object.values(filters).some(v => v !== null) || $('filter-search').value;
$('btn-clear-filters').hidden = !anyActive;
}
$$('th[data-filter]').forEach(th => {
th.addEventListener('click', e => { e.stopPropagation(); openFilterPopover(th.dataset.filter, th); });
});
document.addEventListener('click', e => {
if (!popover.hidden && !popover.contains(e.target)) closeFilterPopover();
});
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeFilterPopover(); });
$('filter-search').addEventListener('input', () => { updateFilterIndicators(); renderTable(); });
$('btn-clear-filters').addEventListener('click', () => {
Object.keys(filters).forEach(k => { filters[k] = null; });
$('filter-search').value = '';
updateFilterIndicators(); renderTable(); closeFilterPopover();
});
// ── Match function ────────────────────────────────────────────
function rowMatchesFilters(tune, entry) {
const search = $('filter-search').value.toLowerCase();
if (search && !tune.name?.toLowerCase().includes(search)) return false;
if (filters.key && tune.key?.toLowerCase() !== filters.key.toLowerCase()) return false;
if (filters.source && String(tune.source_id) !== filters.source) return false;
if (filters.modal !== null && tune.modal !== filters.modal) return false;
if (filters.instrument && (!entry || String(entry.instrument_id) !== filters.instrument)) return false;
if (entry) {
if (filters.tuning && String(entry.tuning_id) !== filters.tuning) return false;
if (filters.callable !== null && entry.callable !== filters.callable) return false;
if (filters.review !== null && entry.review !== filters.review) return false;
if (filters.to_learn !== null && entry.to_learn !== filters.to_learn) return false;
if (filters.difficulty && entry.difficulty?.toLowerCase() !== filters.difficulty) return false;
}
return true;
}
// ── Sorting ───────────────────────────────────────────────────
function sortTunes(tunes) {
return [...tunes].sort((a, b) => {
let av = a[sortCol] ?? '';
let bv = b[sortCol] ?? '';
if (typeof av === 'string') av = av.toLowerCase();
if (typeof bv === 'string') bv = bv.toLowerCase();
if (av < bv) return sortDir === 'asc' ? -1 : 1;
if (av > bv) return sortDir === 'asc' ? 1 : -1;
return 0;
});
}
$$('#tune-table thead th[data-col]').forEach(th => {
th.addEventListener('click', () => {
const col = th.dataset.col;
sortDir = sortCol === col && sortDir === 'asc' ? 'desc' : 'asc';
sortCol = col;
$$('#tune-table thead th').forEach(t => t.classList.remove('sort-asc', 'sort-desc'));
th.classList.add(sortDir === 'asc' ? 'sort-asc' : 'sort-desc');
renderTable();
});
});
// ── Render ────────────────────────────────────────────────────
function renderTable() {
const sorted = sortTunes(allTunes);
const rows = buildRows(sorted).filter(({ tune, entry }) => rowMatchesFilters(tune, entry));
tbody.innerHTML = '';
emptyMsg.hidden = rows.length > 0;
rows.forEach(({ tune, entry }) => {
const hasNotes = (tune.notes?.length > 0) || (entry?.notes?.length > 0);
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(tune.name ?? '')}</td>
<td>${esc(tune.key ?? '')}</td>
<td>${esc(tune.source?.name ?? '')}</td>
<td>${tune.modal ? '<span class="pill pill-yes">Yes</span>' : '<span class="pill" style="background:#222">—</span>'}</td>
<td>${esc(entry?.instrument_name ?? '')}</td>
<td>${esc(entry?.tuning ?? '')}</td>
<td>${pillBool(entry?.callable)}</td>
<td>${pillBool(entry?.review, true)}</td>
<td>${pillBool(entry?.to_learn, false, true)}</td>
<td>${hasNotes
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
: ''}</td>
<td><button class="action-btn btn-edit-row"
data-tune-id="${tune.id}"
data-entry-id="${entry?.id ?? ''}"
title="Edit"
${unlocked ? '' : 'hidden'}></button></td>`;
tbody.appendChild(tr);
});
tbody.querySelectorAll('.btn-notes').forEach(b =>
b.addEventListener('click', () => openNotesModal(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null)));
tbody.querySelectorAll('.btn-edit-row').forEach(b =>
b.addEventListener('click', () => openEdit(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null)));
}
function pillBool(val, warnIfTrue = false, neutralIfTrue = false) {
if (val === null || val === undefined) return '<span class="pill" style="background:#222">—</span>';
if (val === true) {
if (neutralIfTrue || warnIfTrue) return '<span class="pill pill-warn">Yes</span>';
return '<span class="pill pill-yes">Yes</span>';
}
return '<span class="pill pill-no">No</span>';
}
// ── Add / Edit modal ──────────────────────────────────────────
btnAddTune.addEventListener('click', openAdd);
function openAdd() {
editingTuneId = null;
editingEntryId = null;
$('modal-title').textContent = 'Add tune';
tuneForm.reset();
refsList.innerHTML = '';
sourceInput.value = '';
populateInstrumentSelect();
instrumentSelect.value = '';
btnDeleteTune.hidden = true;
btnEditNotes.hidden = true;
tuneModal.showModal();
}
function openEdit(tuneId, entryId) {
editingTuneId = tuneId;
editingEntryId = entryId;
const tune = allTunes.find(t => t.id === tuneId);
const entry = entryId ? tune?.instruments.find(e => e.id === entryId) : null;
$('modal-title').textContent = `Edit: ${tune?.name ?? ''}`;
tuneForm.reset();
tuneForm.elements.name.value = tune?.name ?? '';
tuneForm.elements.key.value = tune?.key ?? '';
tuneForm.elements.modal.checked = !!tune?.modal;
sourceInput.value = tune?.source?.name ?? '';
// Instrument entry
populateInstrumentSelect(entry?.instrument_id ?? null);
if (entry) {
tuneForm.elements.inst_tuning.value = entry.tuning ?? '';
tuneForm.elements.inst_learned_from.value = entry.learned_from ?? '';
tuneForm.elements.inst_date_learned.value = entry.date_learned ?? '';
tuneForm.elements.inst_callable.checked = !!entry.callable;
tuneForm.elements.inst_review.checked = !!entry.review;
tuneForm.elements.inst_to_learn.checked = !!entry.to_learn;
tuneForm.elements.inst_difficulty.value = entry.difficulty ?? '';
}
// References
refsList.innerHTML = '';
(tune?.references ?? []).forEach(r => addRefRow(r.link, r.site, r.id));
btnDeleteTune.hidden = false;
btnEditNotes.hidden = false;
tuneModal.showModal();
}
// ── Reference rows ────────────────────────────────────────────
$('btn-add-ref').addEventListener('click', () => addRefRow());
function addRefRow(link = '', site = '', refId = null) {
const row = document.createElement('div');
row.className = 'ref-row';
row.dataset.refId = refId ?? '';
row.innerHTML = `<input type="text" placeholder="URL" value="${esc(link)}" class="ref-link" />
<input type="text" placeholder="Site" value="${esc(site)}" class="ref-site" style="max-width:100px" />
<button type="button" class="remove-btn"></button>`;
row.querySelector('.remove-btn').addEventListener('click', () => row.remove());
refsList.appendChild(row);
}
// ── Save ──────────────────────────────────────────────────────
$('btn-save-tune').addEventListener('click', saveTune);
$('btn-cancel').addEventListener('click', () => tuneModal.close());
async function saveTune() {
const f = tuneForm.elements;
const tuneBody = {
name: f.name.value || null,
key: f.key.value || null,
modal: f.modal.checked,
source_id: resolveSourceId(sourceInput.value),
};
const instId = instrumentSelect.value ? parseInt(instrumentSelect.value) : null;
const instBody = instId ? {
instrument_id: instId,
tuning_id: resolveTuningId(f.inst_tuning.value),
learned_from_id: resolveMusicianId(f.inst_learned_from.value),
date_learned: f.inst_date_learned.value || null,
callable: f.inst_callable.checked,
review: f.inst_review.checked,
to_learn: f.inst_to_learn.checked,
difficulty: f.inst_difficulty.value || null,
} : null;
const references = [...refsList.querySelectorAll('.ref-row')]
.map(row => ({
link: row.querySelector('.ref-link').value || null,
site: row.querySelector('.ref-site').value || null,
}))
.filter(r => r.link || r.site);
try {
let tuneId = editingTuneId;
if (tuneId === null) {
// Create tune
const created = await apiFetch('/tunes/', {
method: 'POST',
body: JSON.stringify({ ...tuneBody, references }),
});
tuneId = created.id;
// Add instrument entry if selected
if (instBody) {
await apiFetch(`/tunes/${tuneId}/instruments`, {
method: 'POST', body: JSON.stringify(instBody),
});
}
} else {
// Update tune core
await apiFetch(`/tunes/${tuneId}`, { method: 'PATCH', body: JSON.stringify(tuneBody) });
// Update or create instrument entry
if (instBody) {
if (editingEntryId) {
await apiFetch(`/tunes/${tuneId}/instruments/${editingEntryId}`, {
method: 'PATCH', body: JSON.stringify(instBody),
});
} else {
await apiFetch(`/tunes/${tuneId}/instruments`, {
method: 'POST', body: JSON.stringify(instBody),
});
}
}
}
tuneModal.close();
await loadAll();
} catch (e) {
setStatus(e.message, true);
}
}
// ── Delete ────────────────────────────────────────────────────
btnEditNotes.addEventListener('click', () => {
tuneModal.close();
openNotesModal(editingTuneId, editingEntryId);
});
btnDeleteTune.addEventListener('click', async () => {
const tune = allTunes.find(t => t.id === editingTuneId);
if (!confirm(`Delete "${tune?.name ?? editingTuneId}" and all its data?`)) return;
try {
await apiFetch(`/tunes/${editingTuneId}`, { method: 'DELETE' });
tuneModal.close();
await loadAll();
} catch (e) {
setStatus(e.message, true);
}
});
// ── Notes modal ───────────────────────────────────────────────
async function openNotesModal(tuneId, entryId) {
notesTuneId = tuneId;
notesEntryId = entryId;
const tune = allTunes.find(t => t.id === tuneId);
const entry = entryId ? tune?.instruments.find(e => e.id === entryId) : null;
$('notes-modal-title').textContent = `Notes — ${tune?.name ?? ''}`;
$('inst-notes-heading').textContent = entry
? `${entry.instrument_name} notes`
: 'Instrument notes';
$('inst-notes-section').hidden = !entry;
renderTuneNotes(tune?.notes ?? []);
renderInstNotes(entry?.notes ?? []);
$('btn-add-tune-note').hidden = !unlocked;
$('btn-add-inst-note').hidden = !unlocked || !entry;
notesModal.showModal();
}
function renderTuneNotes(notes) {
const list = $('tune-notes-list');
list.innerHTML = '';
notes.forEach(n => {
const row = document.createElement('div');
row.className = 'note-row';
row.innerHTML = `<span class="note-text">${esc(n.note)}</span>
<span class="note-edit-controls" ${unlocked ? '' : 'hidden'}>
<button class="action-btn btn-edit-note" data-id="${n.id}"></button>
<button class="action-btn btn-delete-note remove-btn" data-id="${n.id}"></button>
</span>`;
row.querySelector('.btn-edit-note')?.addEventListener('click', () => editTuneNote(n));
row.querySelector('.btn-delete-note')?.addEventListener('click', () => deleteTuneNote(n.id));
list.appendChild(row);
});
}
function renderInstNotes(notes) {
const list = $('inst-notes-list');
list.innerHTML = '';
notes.forEach(n => {
const row = document.createElement('div');
row.className = 'note-row';
row.innerHTML = `<span class="note-text">${esc(n.note)}</span>
<span class="note-edit-controls" ${unlocked ? '' : 'hidden'}>
<button class="action-btn btn-edit-note" data-id="${n.id}"></button>
<button class="action-btn btn-delete-note remove-btn" data-id="${n.id}"></button>
</span>`;
row.querySelector('.btn-edit-note')?.addEventListener('click', () => editInstNote(n));
row.querySelector('.btn-delete-note')?.addEventListener('click', () => deleteInstNote(n.id));
list.appendChild(row);
});
}
$('btn-add-tune-note').addEventListener('click', async () => {
const text = prompt('Note:');
if (!text) return;
try {
await apiFetch(`/tunes/${notesTuneId}/notes`, { method: 'POST', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
});
$('btn-add-inst-note').addEventListener('click', async () => {
const text = prompt('Note:');
if (!text) return;
try {
await apiFetch(`/tunes/${notesTuneId}/instruments/${notesEntryId}/notes`,
{ method: 'POST', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
});
async function editTuneNote(n) {
const text = prompt('Edit note:', n.note);
if (text === null) return;
try {
await apiFetch(`/tunes/${notesTuneId}/notes/${n.id}`,
{ method: 'PATCH', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function deleteTuneNote(noteId) {
if (!confirm('Delete this note?')) return;
try {
await apiFetch(`/tunes/${notesTuneId}/notes/${noteId}`, { method: 'DELETE' });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function editInstNote(n) {
const text = prompt('Edit note:', n.note);
if (text === null) return;
try {
await apiFetch(`/tunes/${notesTuneId}/instruments/${notesEntryId}/notes/${n.id}`,
{ method: 'PATCH', body: JSON.stringify({ note: text }) });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function deleteInstNote(noteId) {
if (!confirm('Delete this note?')) return;
try {
await apiFetch(`/tunes/${notesTuneId}/instruments/${notesEntryId}/notes/${noteId}`,
{ method: 'DELETE' });
await reloadNotesModal();
} catch (e) { setStatus(e.message, true); }
}
async function reloadNotesModal() {
await loadAll();
const tune = allTunes.find(t => t.id === notesTuneId);
const entry = notesEntryId ? tune?.instruments.find(e => e.id === notesEntryId) : null;
renderTuneNotes(tune?.notes ?? []);
renderInstNotes(entry?.notes ?? []);
}
$('btn-notes-close').addEventListener('click', () => notesModal.close());
// ── Utility ───────────────────────────────────────────────────
function esc(str) {
if (str === null || str === undefined) return '';
return String(str)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
// ── Boot ─────────────────────────────────────────────────────
loadAll();