diff --git a/app.js b/app.js index 1548cde..dd8e02a 100644 --- a/app.js +++ b/app.js @@ -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 = ` ${esc(tune.name ?? '')} - ${esc(tune.key ?? '')} ${esc(tune.source?.name ?? '')} + ${esc(tune.key ?? '')} ${tune.modal ? 'Yes' : ''} ${esc(entry?.instrument_name ?? '')} ${esc(entry?.tuning ?? '')} @@ -407,6 +423,9 @@ function renderTable() { ${hasNotes ? `` : ''} + ${tune.references?.length > 0 + ? `` + : ''} + `; + + 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)}`; + 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 = ` - + row.dataset.refId = refId ?? ''; + row.dataset.siteId = siteId ?? ''; + const siteOptions = allReferenceSites + .map(s => ``) + .join(''); + row.innerHTML = ` + + + + `; 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 = '

No references.

'; + } 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 = ` + ${esc(r.link ?? r.site?.name ?? 'link')} + ${meta ? `${esc(meta)}` : ''}`; + 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 = ''; + 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 = ''; + 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 = '

No tunes match the current filters.

'; + 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 + ? `${refCount} ref${refCount !== 1 ? 's' : ''}` + : `0`; + + row.innerHTML = ` +
+ ${esc(tune.name ?? '')} + ${[esc(tune.key ?? ''), esc(tune.source?.name ?? '')].filter(Boolean).join(' · ')} + ${esc(instruments)} + ${refPill} + +
+ `; + + 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, '"'); } +// 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(); diff --git a/index.html b/index.html index 689325c..ab1dba8 100644 --- a/index.html +++ b/index.html @@ -9,11 +9,12 @@
-

🎵 Repertory

+

🪕 Repertory

+
@@ -25,15 +26,16 @@ Name - Key Source - Modal + Key + ModalMod. Instrument Tuning - Callable - Review - To learn + CallableCall. + ReviewRev. + To learnLearn Notes + Refs @@ -47,6 +49,40 @@
+ + + + + +

Bulk Edit References

+
+ + + + + +
+
+ +
+
@@ -106,6 +142,7 @@

References

+
@@ -118,6 +155,15 @@
+ + +

References

+
+ +
+

Notes

diff --git a/style.css b/style.css index 9c3bdf0..3b20c93 100644 --- a/style.css +++ b/style.css @@ -138,6 +138,9 @@ th[data-filter].filter-active::before { } .popover-clear:hover { color: var(--text); } +/* Short/full column header label swap (see responsive section) */ +.col-short { display: none; } + /* ── Buttons ──────────────────────────────────────────────── */ button { cursor: pointer; @@ -156,6 +159,7 @@ main { flex: 1; overflow-x: auto; padding: 1rem 1.25rem; + min-width: 0; /* required for overflow-x: auto to work inside a flex container */ } #tune-table { @@ -345,6 +349,219 @@ fieldset.active .fs-body { display: contents; } /* ── Responsive ───────────────────────────────────────────── */ @media (max-width: 600px) { - #tune-table th:nth-child(n+5), - #tune-table td:nth-child(n+5) { display: none; } + /* Header: let actions wrap onto a second line */ + 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; }