repertory/app.js
2026-06-09 15:04:27 -04:00

725 lines
28 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/* ─────────────────────────────────────────────────────────────
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();