Huge refactor for bulk editing of references and new schema changes in backend

This commit is contained in:
Ian Keane 2026-06-11 10:06:20 -04:00
parent eb130ffcfd
commit 067272b797
3 changed files with 624 additions and 19 deletions

364
app.js
View file

@ -9,6 +9,7 @@ let allSources = [];
let allMusicians = [];
let allInstruments = [];
let allTunings = [];
let allReferenceSites = [];
let sortCol = 'name';
let sortDir = 'asc';
@ -42,6 +43,13 @@ const instrumentSelect = $('instrument-select');
const tuningDatalist = $('tuning-list');
const btnDeleteTune = $('btn-delete-tune');
const btnEditNotes = $('btn-edit-notes');
const btnBulkEdit = $('btn-bulk-edit');
const bulkEditPopover = $('bulk-edit-popover');
const bulkRefsModal = $('bulk-refs-modal');
const bulkRefsList = $('bulk-refs-list');
const refsModal = $('refs-modal');
const refsModalList = $('refs-modal-list');
const refSiteDatalist = $('ref-site-list');
// ── Config ───────────────────────────────────────────────────
const _cfg = window.REPERTORY_CONFIG || {};
@ -68,6 +76,7 @@ function setUnlocked(val) {
unlocked = val;
btnUnlock.hidden = val;
btnAddTune.hidden = !val;
btnBulkEdit.hidden = !val;
// Show/hide edit column header and edit buttons
const editHeader = document.querySelector('.edit-col');
if (editHeader) editHeader.hidden = !val;
@ -105,17 +114,19 @@ function setStatus(msg, isError = false) {
async function loadAll() {
setStatus('Loading…');
try {
[allTunes, allSources, allMusicians, allInstruments, allTunings] = await Promise.all([
[allTunes, allSources, allMusicians, allInstruments, allTunings, allReferenceSites] = await Promise.all([
apiFetch('/tunes/'),
apiFetch('/sources/'),
apiFetch('/musicians/'),
apiFetch('/instruments'),
apiFetch('/tunings/'),
apiFetch('/reference_sites/'),
]);
populateFilterOptions();
populateDatalist(sourceDatalist, allSources);
populateDatalist(musicianDatalist, allMusicians);
populateDatalist(tuningDatalist, allTunings);
populateDatalist(refSiteDatalist, allReferenceSites);
populateInstrumentSelect();
renderTable();
setStatus(`${allTunes.length} tunes loaded`);
@ -153,8 +164,13 @@ function populateFilterOptions() {
async function quickAdd(endpoint, promptText, list, datalist, onDone) {
const name = prompt(promptText);
if (!name) return null;
const existing = list.find(x => x.name.toLowerCase() === name.trim().toLowerCase());
if (existing) {
if (onDone) onDone(existing);
return existing;
}
try {
const item = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ name }) });
const item = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ name: name.trim() }) });
list.push(item);
list.sort((a, b) => a.name.localeCompare(b.name));
if (datalist) populateDatalist(datalist, list);
@ -396,8 +412,8 @@ function renderTable() {
const tr = document.createElement('tr');
tr.innerHTML = `
<td>${esc(tune.name ?? '')}</td>
<td>${esc(tune.key ?? '')}</td>
<td>${esc(tune.source?.name ?? '')}</td>
<td>${esc(tune.key ?? '')}</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>
@ -407,6 +423,9 @@ function renderTable() {
<td>${hasNotes
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
: ''}</td>
<td>${tune.references?.length > 0
? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}" title="References">🔗</button>`
: ''}</td>
<td><button class="action-btn btn-edit-row"
data-tune-id="${tune.id}"
data-entry-id="${entry?.id ?? ''}"
@ -419,6 +438,8 @@ function renderTable() {
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)));
tbody.querySelectorAll('.btn-show-refs').forEach(b =>
b.addEventListener('click', () => openRefsModal(+b.dataset.tuneId)));
}
function pillBool(val, warnIfTrue = false, neutralIfTrue = false) {
@ -476,24 +497,108 @@ function openEdit(tuneId, entryId) {
// References
refsList.innerHTML = '';
(tune?.references ?? []).forEach(r => addRefRow(r.link, r.site, r.id));
(tune?.references ?? []).forEach(r => addRefRow(r.link, r.site?.name ?? '', r.id, r.site_id, r.musicians ?? []));
btnDeleteTune.hidden = false;
btnEditNotes.hidden = false;
tuneModal.showModal();
}
// ── Musician chip picker (shared by ref rows + bulk form) ──────
function makeMusicianPicker(initialMusicians = []) {
let musicians = initialMusicians.map(m => ({ id: m.id, name: m.name }));
const el = document.createElement('div');
el.className = 'ref-musicians-row';
el.innerHTML = `<span class="ref-musician-chips"></span>
<span class="autocomplete-row">
<input type="text" placeholder="Musician" class="ref-musician-input"
list="musician-list" autocomplete="off" />
<button type="button" class="quick-add-btn btn-add-ref-musician" title="Add musician">+</button>
</span>`;
const chipsEl = el.querySelector('.ref-musician-chips');
const input = el.querySelector('.ref-musician-input');
const btnPlus = el.querySelector('.btn-add-ref-musician');
function refresh() {
chipsEl.innerHTML = '';
musicians.forEach(m => {
const chip = document.createElement('span');
chip.className = 'musician-chip';
chip.innerHTML = `${esc(m.name)}<button type="button" class="chip-remove" title="Remove">✕</button>`;
chip.querySelector('.chip-remove').addEventListener('click', () => {
musicians = musicians.filter(x => x.id !== m.id);
refresh();
});
chipsEl.appendChild(chip);
});
}
function addByName(name) {
name = name.trim();
if (!name) return false;
const found = allMusicians.find(m => m.name.toLowerCase() === name.toLowerCase());
if (!found || musicians.some(m => m.id === found.id)) return false;
musicians.push({ id: found.id, name: found.name });
input.value = '';
refresh();
return true;
}
attachTabAutocomplete(input, musicianDatalist);
input.addEventListener('keydown', e => {
if (e.key === 'Enter') { e.preventDefault(); addByName(input.value); }
});
btnPlus.addEventListener('click', async () => {
if (addByName(input.value)) return;
const item = await quickAdd('/musicians/', 'New musician name:', allMusicians, musicianDatalist);
if (item) { musicians.push({ id: item.id, name: item.name }); refresh(); }
});
refresh();
return {
el,
getMusicians: () => musicians.map(m => ({ id: m.id })),
reset: () => { musicians = []; input.value = ''; refresh(); },
};
}
// ── Reference rows ────────────────────────────────────────────
$('btn-add-ref').addEventListener('click', () => addRefRow());
function addRefRow(link = '', site = '', refId = null) {
function addRefRow(link = '', site = '', refId = null, siteId = null, initialMusicians = []) {
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" />
row.dataset.refId = refId ?? '';
row.dataset.siteId = siteId ?? '';
const siteOptions = allReferenceSites
.map(s => `<option value="${s.id}"${s.id === siteId ? ' selected' : ''}>${esc(s.name)}</option>`)
.join('');
row.innerHTML = `<input type="text" placeholder="URL" value="${esc(link)}" class="ref-link" />
<span class="autocomplete-row ref-site-wrap">
<select class="ref-site"><option value=""> site </option>${siteOptions}</select>
<button type="button" class="quick-add-btn btn-quick-add-ref-site" title="Add new site">+</button>
</span>
<button type="button" class="remove-btn"></button>`;
row.querySelector('.remove-btn').addEventListener('click', () => row.remove());
row.querySelector('.btn-quick-add-ref-site').addEventListener('click', async () => {
const item = await quickAdd('/reference_sites/', 'New site name:', allReferenceSites, null);
if (item) {
const sel = row.querySelector('.ref-site');
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name;
sel.appendChild(opt);
sel.value = item.id;
}
});
const picker = makeMusicianPicker(initialMusicians);
row.appendChild(picker.el);
row._getMusicians = picker.getMusicians;
refsList.appendChild(row);
}
@ -525,10 +630,11 @@ async function saveTune() {
const references = [...refsList.querySelectorAll('.ref-row')]
.map(row => ({
link: row.querySelector('.ref-link').value || null,
site: row.querySelector('.ref-site').value || null,
link: row.querySelector('.ref-link').value.trim() || null,
site_id: row.querySelector('.ref-site').value ? parseInt(row.querySelector('.ref-site').value) : null,
musicians: row._getMusicians ? row._getMusicians() : [],
}))
.filter(r => r.link || r.site);
.filter(r => r.link || r.site_id);
try {
let tuneId = editingTuneId;
@ -711,6 +817,225 @@ async function reloadNotesModal() {
$('btn-notes-close').addEventListener('click', () => notesModal.close());
// ── References display modal ────────────────────────────────────
$('btn-refs-modal-close').addEventListener('click', () => refsModal.close());
function openRefsModal(tuneId) {
const tune = allTunes.find(t => t.id === tuneId);
$('refs-modal-title').textContent = `References — ${tune?.name ?? ''}`;
refsModalList.innerHTML = '';
const refs = tune?.references ?? [];
if (refs.length === 0) {
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">No references.</p>';
} else {
refs.forEach(r => {
const item = document.createElement('div');
item.className = 'refs-modal-item';
const musicians = r.musicians?.map(m => m.name).join(', ') ?? '';
const siteName = r.site?.name ?? '';
const meta = [siteName, musicians].filter(Boolean).join(' · ');
item.innerHTML = `
<a href="${esc(r.link ?? '')}" target="_blank" rel="noopener" class="ref-link-anchor">${esc(r.link ?? r.site?.name ?? 'link')}</a>
${meta ? `<span class="ref-meta">${esc(meta)}</span>` : ''}`;
refsModalList.appendChild(item);
});
}
refsModal.showModal();
}
// ── Bulk Edit ─────────────────────────────────────────────
btnBulkEdit.addEventListener('click', e => {
e.stopPropagation();
if (!bulkEditPopover.hidden) { bulkEditPopover.hidden = true; return; }
const rect = btnBulkEdit.getBoundingClientRect();
bulkEditPopover.style.top = (rect.bottom + 4) + 'px';
bulkEditPopover.style.right = (window.innerWidth - rect.right) + 'px';
bulkEditPopover.hidden = false;
});
document.addEventListener('click', e => {
if (!bulkEditPopover.hidden && !bulkEditPopover.contains(e.target) && e.target !== btnBulkEdit) {
bulkEditPopover.hidden = true;
}
});
$('btn-open-bulk-refs').addEventListener('click', () => {
bulkEditPopover.hidden = true;
openBulkRefsModal();
});
$('btn-bulk-refs-close').addEventListener('click', () => bulkRefsModal.close());
['bulk-refs-search', 'bulk-refs-instrument', 'bulk-refs-source',
'bulk-refs-has-refs', 'bulk-refs-sort'].forEach(id => {
const el = $(id);
el.addEventListener('input', renderBulkRefsList);
el.addEventListener('change', renderBulkRefsList);
});
function openBulkRefsModal() {
const instSel = $('bulk-refs-instrument');
instSel.innerHTML = '<option value="">All instruments</option>';
allInstruments.forEach(i => {
const opt = document.createElement('option');
opt.value = i.id;
opt.textContent = i.name;
instSel.appendChild(opt);
});
const srcSel = $('bulk-refs-source');
srcSel.innerHTML = '<option value="">All sources</option>';
allSources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.textContent = s.name;
srcSel.appendChild(opt);
});
renderBulkRefsList();
bulkRefsModal.showModal();
}
function getBulkRefsFiltered() {
const search = $('bulk-refs-search').value.toLowerCase();
const instId = $('bulk-refs-instrument').value;
const srcId = $('bulk-refs-source').value;
const hasRefs = $('bulk-refs-has-refs').value;
const sortKey = $('bulk-refs-sort').value;
let tunes = allTunes.filter(t => {
if (search && !(t.name ?? '').toLowerCase().includes(search)) return false;
if (srcId && t.source_id !== parseInt(srcId)) return false;
if (instId && !t.instruments.some(e => e.instrument_id === parseInt(instId))) return false;
if (hasRefs === 'no' && t.references.length > 0) return false;
if (hasRefs === 'yes' && t.references.length === 0) return false;
return true;
});
tunes = [...tunes].sort((a, b) => {
if (sortKey === 'newest') {
const diff = (b.created_at || '') > (a.created_at || '') ? 1
: (b.created_at || '') < (a.created_at || '') ? -1 : 0;
return diff !== 0 ? diff : b.id - a.id;
}
if (sortKey === 'oldest') {
const diff = (a.created_at || '') > (b.created_at || '') ? 1
: (a.created_at || '') < (b.created_at || '') ? -1 : 0;
return diff !== 0 ? diff : a.id - b.id;
}
if (sortKey === 'name_desc') return (b.name ?? '').localeCompare(a.name ?? '');
return (a.name ?? '').localeCompare(b.name ?? '');
});
return tunes;
}
function renderBulkRefsList() {
const tunes = getBulkRefsFiltered();
bulkRefsList.innerHTML = '';
if (tunes.length === 0) {
bulkRefsList.innerHTML = '<p class="bulk-refs-empty">No tunes match the current filters.</p>';
return;
}
tunes.forEach(tune => {
const row = document.createElement('div');
row.className = 'bulk-tune-row';
row.dataset.tuneId = tune.id;
const instruments = tune.instruments.map(e => e.instrument_name).filter(Boolean).join(', ');
const refCount = tune.references.length;
const refPill = refCount > 0
? `<span class="pill pill-yes">${refCount} ref${refCount !== 1 ? 's' : ''}</span>`
: `<span class="pill" style="background:#222">0</span>`;
row.innerHTML = `
<div class="bulk-tune-main">
<span class="bulk-tune-name">${esc(tune.name ?? '')}</span>
<span class="bulk-tune-meta">${[esc(tune.key ?? ''), esc(tune.source?.name ?? '')].filter(Boolean).join(' · ')}</span>
<span class="bulk-tune-instruments">${esc(instruments)}</span>
<span class="bulk-tune-refs">${refPill}</span>
<button class="btn-bulk-add-ref action-btn">+ ref</button>
</div>
<div class="bulk-ref-form" hidden>
<input type="text" class="bulk-ref-url" placeholder="URL" />
<span class="autocomplete-row">
<select class="bulk-ref-site"><option value=""> site </option>${allReferenceSites.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('')}</select>
<button class="btn-bulk-quick-add-site quick-add-btn" type="button" title="Add new site">+</button>
</span>
<button class="btn-bulk-ref-save">Add</button>
<button class="btn-bulk-ref-cancel remove-btn"></button>
<div class="bulk-ref-musicians-mount"></div>
</div>`;
const form = row.querySelector('.bulk-ref-form');
const urlInput = row.querySelector('.bulk-ref-url');
const siteSel = row.querySelector('.bulk-ref-site');
const btnAdd = row.querySelector('.btn-bulk-add-ref');
const btnSave = row.querySelector('.btn-bulk-ref-save');
const btnCancel = row.querySelector('.btn-bulk-ref-cancel');
const btnQuickAddSite = row.querySelector('.btn-bulk-quick-add-site');
const picker = makeMusicianPicker();
row.querySelector('.bulk-ref-musicians-mount').appendChild(picker.el);
btnAdd.addEventListener('click', () => {
form.hidden = !form.hidden;
if (!form.hidden) urlInput.focus();
});
btnCancel.addEventListener('click', () => {
form.hidden = true;
urlInput.value = '';
siteSel.value = '';
picker.reset();
});
btnQuickAddSite.addEventListener('click', async () => {
const item = await quickAdd('/reference_sites/', 'New site name:', allReferenceSites, null);
if (item) {
// Add the new option to every open site select in the list
bulkRefsList.querySelectorAll('.bulk-ref-site').forEach(sel => {
const opt = document.createElement('option');
opt.value = item.id;
opt.textContent = item.name;
sel.appendChild(opt);
});
siteSel.value = item.id;
}
});
const submitRef = async () => {
const link = urlInput.value.trim();
const siteId = siteSel.value ? parseInt(siteSel.value) : null;
const musicians = picker.getMusicians();
if (!link && !siteId) return;
btnSave.disabled = true;
try {
await apiFetch(`/tunes/${tune.id}/references`, {
method: 'POST',
body: JSON.stringify({ link: link || null, site_id: siteId, musicians }),
});
await loadAll();
renderBulkRefsList();
} catch (e) {
setStatus(e.message, true);
btnSave.disabled = false;
}
};
btnSave.addEventListener('click', submitRef);
urlInput.addEventListener('keydown', e => {
if (e.key === 'Enter') submitRef();
if (e.key === 'Escape') btnCancel.click();
});
bulkRefsList.appendChild(row);
});
}
// ── Utility ───────────────────────────────────────────────────
function esc(str) {
if (str === null || str === undefined) return '';
@ -721,5 +1046,22 @@ function esc(str) {
.replace(/"/g, '&quot;');
}
// Pressing Tab in a datalist-backed input fills the top matching suggestion.
function attachTabAutocomplete(input, datalist) {
input.addEventListener('keydown', e => {
if (e.key !== 'Tab' || !input.value) return;
const val = input.value.toLowerCase();
const opts = [...datalist.options].map(o => o.value);
const match = opts.find(o => o.toLowerCase().startsWith(val))
?? opts.find(o => o.toLowerCase().includes(val));
if (match) input.value = match;
// Don't preventDefault — Tab still moves focus normally
});
}
attachTabAutocomplete(sourceInput, sourceDatalist);
attachTabAutocomplete(tuneForm.elements.inst_tuning, tuningDatalist);
attachTabAutocomplete(tuneForm.elements.inst_learned_from, musicianDatalist);
// ── Boot ─────────────────────────────────────────────────────
loadAll();