Huge refactor for bulk editing of references and new schema changes in backend
This commit is contained in:
parent
eb130ffcfd
commit
067272b797
3 changed files with 624 additions and 19 deletions
364
app.js
364
app.js
|
|
@ -9,6 +9,7 @@ let allSources = [];
|
||||||
let allMusicians = [];
|
let allMusicians = [];
|
||||||
let allInstruments = [];
|
let allInstruments = [];
|
||||||
let allTunings = [];
|
let allTunings = [];
|
||||||
|
let allReferenceSites = [];
|
||||||
let sortCol = 'name';
|
let sortCol = 'name';
|
||||||
let sortDir = 'asc';
|
let sortDir = 'asc';
|
||||||
|
|
||||||
|
|
@ -42,6 +43,13 @@ const instrumentSelect = $('instrument-select');
|
||||||
const tuningDatalist = $('tuning-list');
|
const tuningDatalist = $('tuning-list');
|
||||||
const btnDeleteTune = $('btn-delete-tune');
|
const btnDeleteTune = $('btn-delete-tune');
|
||||||
const btnEditNotes = $('btn-edit-notes');
|
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 ───────────────────────────────────────────────────
|
// ── Config ───────────────────────────────────────────────────
|
||||||
const _cfg = window.REPERTORY_CONFIG || {};
|
const _cfg = window.REPERTORY_CONFIG || {};
|
||||||
|
|
@ -68,6 +76,7 @@ function setUnlocked(val) {
|
||||||
unlocked = val;
|
unlocked = val;
|
||||||
btnUnlock.hidden = val;
|
btnUnlock.hidden = val;
|
||||||
btnAddTune.hidden = !val;
|
btnAddTune.hidden = !val;
|
||||||
|
btnBulkEdit.hidden = !val;
|
||||||
// Show/hide edit column header and edit buttons
|
// Show/hide edit column header and edit buttons
|
||||||
const editHeader = document.querySelector('.edit-col');
|
const editHeader = document.querySelector('.edit-col');
|
||||||
if (editHeader) editHeader.hidden = !val;
|
if (editHeader) editHeader.hidden = !val;
|
||||||
|
|
@ -105,17 +114,19 @@ function setStatus(msg, isError = false) {
|
||||||
async function loadAll() {
|
async function loadAll() {
|
||||||
setStatus('Loading…');
|
setStatus('Loading…');
|
||||||
try {
|
try {
|
||||||
[allTunes, allSources, allMusicians, allInstruments, allTunings] = await Promise.all([
|
[allTunes, allSources, allMusicians, allInstruments, allTunings, allReferenceSites] = await Promise.all([
|
||||||
apiFetch('/tunes/'),
|
apiFetch('/tunes/'),
|
||||||
apiFetch('/sources/'),
|
apiFetch('/sources/'),
|
||||||
apiFetch('/musicians/'),
|
apiFetch('/musicians/'),
|
||||||
apiFetch('/instruments'),
|
apiFetch('/instruments'),
|
||||||
apiFetch('/tunings/'),
|
apiFetch('/tunings/'),
|
||||||
|
apiFetch('/reference_sites/'),
|
||||||
]);
|
]);
|
||||||
populateFilterOptions();
|
populateFilterOptions();
|
||||||
populateDatalist(sourceDatalist, allSources);
|
populateDatalist(sourceDatalist, allSources);
|
||||||
populateDatalist(musicianDatalist, allMusicians);
|
populateDatalist(musicianDatalist, allMusicians);
|
||||||
populateDatalist(tuningDatalist, allTunings);
|
populateDatalist(tuningDatalist, allTunings);
|
||||||
|
populateDatalist(refSiteDatalist, allReferenceSites);
|
||||||
populateInstrumentSelect();
|
populateInstrumentSelect();
|
||||||
renderTable();
|
renderTable();
|
||||||
setStatus(`${allTunes.length} tunes loaded`);
|
setStatus(`${allTunes.length} tunes loaded`);
|
||||||
|
|
@ -153,8 +164,13 @@ function populateFilterOptions() {
|
||||||
async function quickAdd(endpoint, promptText, list, datalist, onDone) {
|
async function quickAdd(endpoint, promptText, list, datalist, onDone) {
|
||||||
const name = prompt(promptText);
|
const name = prompt(promptText);
|
||||||
if (!name) return null;
|
if (!name) return null;
|
||||||
|
const existing = list.find(x => x.name.toLowerCase() === name.trim().toLowerCase());
|
||||||
|
if (existing) {
|
||||||
|
if (onDone) onDone(existing);
|
||||||
|
return existing;
|
||||||
|
}
|
||||||
try {
|
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.push(item);
|
||||||
list.sort((a, b) => a.name.localeCompare(b.name));
|
list.sort((a, b) => a.name.localeCompare(b.name));
|
||||||
if (datalist) populateDatalist(datalist, list);
|
if (datalist) populateDatalist(datalist, list);
|
||||||
|
|
@ -396,8 +412,8 @@ function renderTable() {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
<td>${esc(tune.name ?? '')}</td>
|
<td>${esc(tune.name ?? '')}</td>
|
||||||
<td>${esc(tune.key ?? '')}</td>
|
|
||||||
<td>${esc(tune.source?.name ?? '')}</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>${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?.instrument_name ?? '')}</td>
|
||||||
<td>${esc(entry?.tuning ?? '')}</td>
|
<td>${esc(entry?.tuning ?? '')}</td>
|
||||||
|
|
@ -407,6 +423,9 @@ function renderTable() {
|
||||||
<td>${hasNotes
|
<td>${hasNotes
|
||||||
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
|
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
|
||||||
: ''}</td>
|
: ''}</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"
|
<td><button class="action-btn btn-edit-row"
|
||||||
data-tune-id="${tune.id}"
|
data-tune-id="${tune.id}"
|
||||||
data-entry-id="${entry?.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)));
|
b.addEventListener('click', () => openNotesModal(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null)));
|
||||||
tbody.querySelectorAll('.btn-edit-row').forEach(b =>
|
tbody.querySelectorAll('.btn-edit-row').forEach(b =>
|
||||||
b.addEventListener('click', () => openEdit(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null)));
|
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) {
|
function pillBool(val, warnIfTrue = false, neutralIfTrue = false) {
|
||||||
|
|
@ -476,24 +497,108 @@ function openEdit(tuneId, entryId) {
|
||||||
|
|
||||||
// References
|
// References
|
||||||
refsList.innerHTML = '';
|
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;
|
btnDeleteTune.hidden = false;
|
||||||
btnEditNotes.hidden = false;
|
btnEditNotes.hidden = false;
|
||||||
tuneModal.showModal();
|
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 ────────────────────────────────────────────
|
// ── Reference rows ────────────────────────────────────────────
|
||||||
$('btn-add-ref').addEventListener('click', () => addRefRow());
|
$('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');
|
const row = document.createElement('div');
|
||||||
row.className = 'ref-row';
|
row.className = 'ref-row';
|
||||||
row.dataset.refId = refId ?? '';
|
row.dataset.refId = refId ?? '';
|
||||||
row.innerHTML = `<input type="text" placeholder="URL" value="${esc(link)}" class="ref-link" />
|
row.dataset.siteId = siteId ?? '';
|
||||||
<input type="text" placeholder="Site" value="${esc(site)}" class="ref-site" style="max-width:100px" />
|
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>`;
|
<button type="button" class="remove-btn">✕</button>`;
|
||||||
row.querySelector('.remove-btn').addEventListener('click', () => row.remove());
|
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);
|
refsList.appendChild(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -525,10 +630,11 @@ async function saveTune() {
|
||||||
|
|
||||||
const references = [...refsList.querySelectorAll('.ref-row')]
|
const references = [...refsList.querySelectorAll('.ref-row')]
|
||||||
.map(row => ({
|
.map(row => ({
|
||||||
link: row.querySelector('.ref-link').value || null,
|
link: row.querySelector('.ref-link').value.trim() || null,
|
||||||
site: row.querySelector('.ref-site').value || 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 {
|
try {
|
||||||
let tuneId = editingTuneId;
|
let tuneId = editingTuneId;
|
||||||
|
|
@ -711,6 +817,225 @@ async function reloadNotesModal() {
|
||||||
|
|
||||||
$('btn-notes-close').addEventListener('click', () => notesModal.close());
|
$('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 ───────────────────────────────────────────────────
|
// ── Utility ───────────────────────────────────────────────────
|
||||||
function esc(str) {
|
function esc(str) {
|
||||||
if (str === null || str === undefined) return '';
|
if (str === null || str === undefined) return '';
|
||||||
|
|
@ -721,5 +1046,22 @@ function esc(str) {
|
||||||
.replace(/"/g, '"');
|
.replace(/"/g, '"');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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 ─────────────────────────────────────────────────────
|
// ── Boot ─────────────────────────────────────────────────────
|
||||||
loadAll();
|
loadAll();
|
||||||
|
|
|
||||||
58
index.html
58
index.html
|
|
@ -9,11 +9,12 @@
|
||||||
<body>
|
<body>
|
||||||
|
|
||||||
<header>
|
<header>
|
||||||
<h1>🎵 Repertory</h1>
|
<h1>🪕 Repertory</h1>
|
||||||
<div id="header-actions">
|
<div id="header-actions">
|
||||||
<input type="search" id="filter-search" placeholder="Search…" />
|
<input type="search" id="filter-search" placeholder="Search…" />
|
||||||
<button id="btn-clear-filters" hidden>✕ Clear filters</button>
|
<button id="btn-clear-filters" hidden>✕ Clear filters</button>
|
||||||
<span id="status"></span>
|
<span id="status"></span>
|
||||||
|
<button id="btn-bulk-edit" hidden>⚡ Bulk Edit</button>
|
||||||
<button id="btn-add-tune" hidden>+ Add tune</button>
|
<button id="btn-add-tune" hidden>+ Add tune</button>
|
||||||
<button id="btn-unlock">🔒 Unlock</button>
|
<button id="btn-unlock">🔒 Unlock</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -25,15 +26,16 @@
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th data-col="name">Name <span class="sort-indicator"></span></th>
|
<th data-col="name">Name <span class="sort-indicator"></span></th>
|
||||||
<th data-filter="key">Key</th>
|
|
||||||
<th data-filter="source">Source</th>
|
<th data-filter="source">Source</th>
|
||||||
<th data-filter="modal">Modal</th>
|
<th data-filter="key">Key</th>
|
||||||
|
<th data-filter="modal"><span class="col-full">Modal</span><span class="col-short">Mod.</span></th>
|
||||||
<th data-filter="instrument">Instrument</th>
|
<th data-filter="instrument">Instrument</th>
|
||||||
<th data-filter="tuning">Tuning</th>
|
<th data-filter="tuning">Tuning</th>
|
||||||
<th data-filter="callable">Callable</th>
|
<th data-filter="callable"><span class="col-full">Callable</span><span class="col-short">Call.</span></th>
|
||||||
<th data-filter="review">Review</th>
|
<th data-filter="review"><span class="col-full">Review</span><span class="col-short">Rev.</span></th>
|
||||||
<th data-filter="to_learn">To learn</th>
|
<th data-filter="to_learn"><span class="col-full">To learn</span><span class="col-short">Learn</span></th>
|
||||||
<th>Notes</th>
|
<th>Notes</th>
|
||||||
|
<th>Refs</th>
|
||||||
<th class="edit-col" hidden></th>
|
<th class="edit-col" hidden></th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
@ -47,6 +49,40 @@
|
||||||
<div id="filter-popover-inner"></div>
|
<div id="filter-popover-inner"></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bulk edit popover ─────────────────────────────────────── -->
|
||||||
|
<div id="bulk-edit-popover" hidden>
|
||||||
|
<button id="btn-open-bulk-refs">📎 Bulk Edit References</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Bulk edit references modal ───────────────────────────── -->
|
||||||
|
<dialog id="bulk-refs-modal">
|
||||||
|
<h2>Bulk Edit References</h2>
|
||||||
|
<div id="bulk-refs-controls">
|
||||||
|
<input type="search" id="bulk-refs-search" placeholder="Filter by name…" />
|
||||||
|
<select id="bulk-refs-instrument">
|
||||||
|
<option value="">All instruments</option>
|
||||||
|
</select>
|
||||||
|
<select id="bulk-refs-source">
|
||||||
|
<option value="">All sources</option>
|
||||||
|
</select>
|
||||||
|
<select id="bulk-refs-has-refs">
|
||||||
|
<option value="">All tunes</option>
|
||||||
|
<option value="no">No refs yet</option>
|
||||||
|
<option value="yes">Has refs</option>
|
||||||
|
</select>
|
||||||
|
<select id="bulk-refs-sort">
|
||||||
|
<option value="newest">Newest first</option>
|
||||||
|
<option value="oldest">Oldest first</option>
|
||||||
|
<option value="name_asc">Name A→Z</option>
|
||||||
|
<option value="name_desc">Name Z→A</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div id="bulk-refs-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-bulk-refs-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<!-- ── Add / Edit tune modal ────────────────────────────────── -->
|
<!-- ── Add / Edit tune modal ────────────────────────────────── -->
|
||||||
<dialog id="tune-modal">
|
<dialog id="tune-modal">
|
||||||
<form id="tune-form" method="dialog">
|
<form id="tune-form" method="dialog">
|
||||||
|
|
@ -106,6 +142,7 @@
|
||||||
<div id="refs-section">
|
<div id="refs-section">
|
||||||
<h3>References</h3>
|
<h3>References</h3>
|
||||||
<div id="refs-list"></div>
|
<div id="refs-list"></div>
|
||||||
|
<datalist id="ref-site-list"></datalist>
|
||||||
<button type="button" id="btn-add-ref">+ reference</button>
|
<button type="button" id="btn-add-ref">+ reference</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -118,6 +155,15 @@
|
||||||
</form>
|
</form>
|
||||||
</dialog>
|
</dialog>
|
||||||
|
|
||||||
|
<!-- ── References display modal ─────────────────────────────── -->
|
||||||
|
<dialog id="refs-modal">
|
||||||
|
<h2 id="refs-modal-title">References</h2>
|
||||||
|
<div id="refs-modal-list"></div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-refs-modal-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
<!-- ── Notes modal ──────────────────────────────────────────── -->
|
<!-- ── Notes modal ──────────────────────────────────────────── -->
|
||||||
<dialog id="notes-modal">
|
<dialog id="notes-modal">
|
||||||
<h2 id="notes-modal-title">Notes</h2>
|
<h2 id="notes-modal-title">Notes</h2>
|
||||||
|
|
|
||||||
221
style.css
221
style.css
|
|
@ -138,6 +138,9 @@ th[data-filter].filter-active::before {
|
||||||
}
|
}
|
||||||
.popover-clear:hover { color: var(--text); }
|
.popover-clear:hover { color: var(--text); }
|
||||||
|
|
||||||
|
/* Short/full column header label swap (see responsive section) */
|
||||||
|
.col-short { display: none; }
|
||||||
|
|
||||||
/* ── Buttons ──────────────────────────────────────────────── */
|
/* ── Buttons ──────────────────────────────────────────────── */
|
||||||
button {
|
button {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
|
|
@ -156,6 +159,7 @@ main {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
padding: 1rem 1.25rem;
|
padding: 1rem 1.25rem;
|
||||||
|
min-width: 0; /* required for overflow-x: auto to work inside a flex container */
|
||||||
}
|
}
|
||||||
|
|
||||||
#tune-table {
|
#tune-table {
|
||||||
|
|
@ -345,6 +349,219 @@ fieldset.active .fs-body { display: contents; }
|
||||||
|
|
||||||
/* ── Responsive ───────────────────────────────────────────── */
|
/* ── Responsive ───────────────────────────────────────────── */
|
||||||
@media (max-width: 600px) {
|
@media (max-width: 600px) {
|
||||||
#tune-table th:nth-child(n+5),
|
/* Header: let actions wrap onto a second line */
|
||||||
#tune-table td:nth-child(n+5) { display: none; }
|
header { flex-wrap: wrap; }
|
||||||
|
#header-actions { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Table: scroll horizontally instead of hiding columns */
|
||||||
|
#tune-table { width: max-content; min-width: 100%; }
|
||||||
|
|
||||||
|
/* Tighter cell padding on mobile */
|
||||||
|
#tune-table th,
|
||||||
|
#tune-table td { padding: .4rem .35rem; }
|
||||||
|
|
||||||
|
/* Short column headers: swap to abbreviated label on mobile */
|
||||||
|
.col-full { display: none; }
|
||||||
|
.col-short { display: inline; }
|
||||||
|
|
||||||
|
/* Modals: fullscreen */
|
||||||
|
dialog {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
max-height: 100%;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Bulk edit popover ────────────────────────────────────── */
|
||||||
|
#bulk-edit-popover {
|
||||||
|
position: fixed;
|
||||||
|
z-index: 100;
|
||||||
|
background: var(--surface);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .4rem;
|
||||||
|
min-width: 200px;
|
||||||
|
box-shadow: 0 4px 16px rgba(0,0,0,.4);
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .2rem;
|
||||||
|
}
|
||||||
|
#bulk-edit-popover:not([hidden]) { display: flex; }
|
||||||
|
|
||||||
|
#bulk-edit-popover button {
|
||||||
|
text-align: left;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text);
|
||||||
|
padding: .4rem .65rem;
|
||||||
|
border-radius: var(--radius);
|
||||||
|
font-size: .875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-edit-popover button:hover { background: rgba(255,255,255,.08); opacity: 1; }
|
||||||
|
|
||||||
|
#btn-bulk-edit { background: transparent; border: 1px solid var(--border); color: var(--muted); }
|
||||||
|
|
||||||
|
/* ── Bulk refs modal ──────────────────────────────────────── */
|
||||||
|
#bulk-refs-modal { max-width: 760px; }
|
||||||
|
|
||||||
|
#bulk-refs-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: .5rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
margin-bottom: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-refs-controls input,
|
||||||
|
#bulk-refs-controls select {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#bulk-refs-controls input[type="search"] { flex: 1; min-width: 140px; }
|
||||||
|
|
||||||
|
#bulk-refs-list {
|
||||||
|
max-height: 55vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
margin-bottom: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-tune-row {
|
||||||
|
padding: .45rem .65rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.bulk-tune-row:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.bulk-tune-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .6rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-tune-name {
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: .875rem;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-tune-meta,
|
||||||
|
.bulk-tune-instruments {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-tune-refs { white-space: nowrap; }
|
||||||
|
|
||||||
|
.btn-bulk-add-ref { flex-shrink: 0; font-size: .75rem; padding: .2rem .5rem; }
|
||||||
|
|
||||||
|
.bulk-ref-form {
|
||||||
|
display: flex;
|
||||||
|
gap: .4rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-top: .35rem;
|
||||||
|
padding-top: .35rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-ref-form input {
|
||||||
|
background: var(--bg);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
color: var(--text);
|
||||||
|
border-radius: var(--radius);
|
||||||
|
padding: .3rem .5rem;
|
||||||
|
font-size: .85rem;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bulk-ref-form .bulk-ref-site { max-width: 110px; flex: none; }
|
||||||
|
|
||||||
|
.btn-bulk-ref-save { background: var(--accent); flex-shrink: 0; font-size: .8rem; padding: .25rem .6rem; }
|
||||||
|
|
||||||
|
.bulk-refs-empty { color: var(--muted); text-align: center; padding: 1.5rem; font-size: .875rem; }
|
||||||
|
|
||||||
|
/* ── Musician chips ──────────────────────────────────────────── */
|
||||||
|
.ref-musicians-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .35rem;
|
||||||
|
width: 100%;
|
||||||
|
margin-top: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-musician-chips {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: .3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.musician-chip {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: .2rem;
|
||||||
|
background: var(--accent2);
|
||||||
|
border-radius: 99px;
|
||||||
|
padding: .15rem .45rem .15rem .55rem;
|
||||||
|
font-size: .75rem;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chip-remove {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: inherit;
|
||||||
|
padding: 0 .1rem;
|
||||||
|
font-size: .65rem;
|
||||||
|
line-height: 1;
|
||||||
|
opacity: .7;
|
||||||
|
}
|
||||||
|
.chip-remove:hover { opacity: 1; }
|
||||||
|
|
||||||
|
/* Allow ref rows to wrap so the musician row sits on its own line */
|
||||||
|
.ref-row { flex-wrap: wrap; }
|
||||||
|
|
||||||
|
/* Same for bulk ref form */
|
||||||
|
.bulk-ref-form { flex-wrap: wrap; }
|
||||||
|
.bulk-ref-musicians-mount { width: 100%; }
|
||||||
|
.bulk-ref-musicians-mount .ref-musicians-row { margin-top: 0; }
|
||||||
|
|
||||||
|
/* ── References display modal ────────────────────────────────── */
|
||||||
|
.refs-modal-item {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: .2rem;
|
||||||
|
padding: .5rem 0;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.refs-modal-item:last-child { border-bottom: none; }
|
||||||
|
|
||||||
|
.ref-link-anchor {
|
||||||
|
color: var(--accent);
|
||||||
|
font-size: .875rem;
|
||||||
|
word-break: break-all;
|
||||||
|
text-decoration: none;
|
||||||
|
}
|
||||||
|
.ref-link-anchor:hover { text-decoration: underline; }
|
||||||
|
|
||||||
|
.ref-meta {
|
||||||
|
font-size: .8rem;
|
||||||
|
color: var(--muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.ref-site-wrap { max-width: 160px; }
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue