/* ───────────────────────────────────────────────────────────── Repertory – frontend app Plain JS, no build step, no framework. ───────────────────────────────────────────────────────────── */ // ── State ──────────────────────────────────────────────────── let allTunes = []; let allSources = []; let allMusicians = []; let allInstruments = []; let allTunings = []; let allReferenceSites = []; 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'); 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 || {}; 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; btnBulkEdit.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, 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`); } 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 = ''; 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; 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: name.trim() }) }); 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, status: null, difficulty: null, }; const popover = $('filter-popover'); const popoverInner = $('filter-popover-inner'); let activeFilterKey = null; const BOOL_FILTERS = new Set(['modal']); const DROPDOWN_FILTERS = new Set(['source', 'instrument', 'tuning', 'difficulty', 'status']); 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' }; 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']]; if (key === 'status') return [...none, ['callable', 'Callable'], ['review', 'Needs Review'], ['to_learn', 'To Learn']]; 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.status && entry.status !== filters.status) 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); // ── Mobile 2-line card data ────────────────────────────────── const nameRaw = tune.name ?? ''; const sourceRaw = tune.source?.name ?? ''; const instRaw = entry?.instrument_name ?? ''; const tuningRaw = entry?.tuning ?? ''; const instStr = instRaw ? (tuningRaw ? `${instRaw} (${tuningRaw})` : instRaw) : ''; const keyStr = tune.key ? (tune.modal ? `${tune.key} modal` : tune.key) : (tune.modal ? 'modal' : ''); const metaRaw = [instStr, keyStr].filter(Boolean).join(' · '); const mobPills = entry ? pillStatus(entry.status, 'mob-pill') : ''; const mobActions = [ hasNotes ? `` : '', tune.references?.length > 0 ? `` : '', ``, ].join(''); const tr = document.createElement('tr'); tr.innerHTML = ` ${esc(nameRaw)} ${esc(sourceRaw)} ${esc(tune.key ?? '')} ${tune.modal ? 'Yes' : ''} ${esc(entry?.instrument_name ?? '')} ${esc(entry?.tuning ?? '')} ${pillStatus(entry?.status)} ${hasNotes ? `` : ''} ${tune.references?.length > 0 ? `` : ''}
${esc(nameRaw)} ${sourceRaw ? `(${esc(sourceRaw)})` : ''}
${mobPills}
${esc(metaRaw)} ${mobActions}
`; 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))); tbody.querySelectorAll('.btn-show-refs').forEach(b => b.addEventListener('click', () => openRefsModal(+b.dataset.tuneId))); } function pillBool(val, warnIfTrue = false, neutralIfTrue = false) { if (val === null || val === undefined) return ''; if (val === true) { if (neutralIfTrue || warnIfTrue) return 'Yes'; return 'Yes'; } return 'No'; } function pillStatus(status, extraClass = '') { const c = extraClass ? ' ' + extraClass : ''; if (status === 'callable') return `Callable`; if (status === 'review') return `Needs Review`; if (status === 'to_learn') return `To Learn`; return ``; } // ── 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_status.value = entry.status ?? ''; tuneForm.elements.inst_difficulty.value = entry.difficulty ?? ''; } // References refsList.innerHTML = ''; (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 = ` `; 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, siteId = null, initialMusicians = []) { const row = document.createElement('div'); row.className = 'ref-row'; 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); } // ── 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, status: f.inst_status.value || null, difficulty: f.inst_difficulty.value || null, } : null; const references = [...refsList.querySelectorAll('.ref-row')] .map(row => ({ 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_id); 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 = `${esc(n.note)} `; 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 = `${esc(n.note)} `; 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()); // ── 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(); } // ── Call / Learn mode ──────────────────────────────────────────── const callModal = $('call-modal'); let callMode = 'callable'; // 'callable' | 'to_learn' let callStep = 0; let callInstrumentId = null; let callTuningId = null; function callGetEntries() { const result = []; for (const tune of allTunes) for (const entry of tune.instruments) if (entry.status === callMode) result.push({ tune, entry }); return result; } function callGetInstruments() { const map = new Map(); callGetEntries().forEach(({ entry }) => { if (!map.has(entry.instrument_id)) map.set(entry.instrument_id, entry.instrument_name); }); return [...map.entries()].map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)); } function callGetTunings() { const map = new Map(); callGetEntries() .filter(({ entry }) => callInstrumentId === null || entry.instrument_id === callInstrumentId) .forEach(({ entry }) => { if (entry.tuning_id != null && !map.has(entry.tuning_id)) map.set(entry.tuning_id, entry.tuning); }); return [...map.entries()].map(([id, name]) => ({ id, name })) .sort((a, b) => a.name.localeCompare(b.name)); } function callGetKeys() { const map = new Map(); callGetEntries() .filter(({ entry }) => (callInstrumentId === null || entry.instrument_id === callInstrumentId) && (callTuningId === null || entry.tuning_id === callTuningId) ) .forEach(({ tune }) => { if (tune.key) { const k = tune.key + '|' + (tune.modal ? '1' : '0'); if (!map.has(k)) map.set(k, { key: tune.key, modal: !!tune.modal }); } }); return [...map.values()] .sort((a, b) => a.key.localeCompare(b.key) || (a.modal ? 1 : -1)); } function openCallModal(mode) { callMode = mode; callStep = 0; callInstrumentId = null; callTuningId = null; renderCallStep(); // showModal() is called inside renderCallStep() only when there are // multiple options to show — if everything auto-skipped the modal // never opens and the filter is applied instantly. } function renderCallStep() { const items = callStep === 0 ? callGetInstruments() : callStep === 1 ? callGetTunings() : callGetKeys(); // Auto-advance if only one option if (items.length === 1) { callSelect(callStep === 2 ? items[0].key : items[0].id, callStep === 2 ? items[0].modal : null); return; } // Multiple options — open the modal now if not already open if (!callModal.open) callModal.showModal(); $('call-title').textContent = ['Instrument', 'Tuning', 'Key'][callStep]; $('call-back').hidden = callStep === 0; const opts = $('call-options'); opts.innerHTML = ''; const allBtn = document.createElement('button'); allBtn.className = 'call-option-btn call-all-btn'; allBtn.textContent = 'All'; allBtn.addEventListener('click', () => callSelect(null, null)); opts.appendChild(allBtn); items.forEach(item => { const btn = document.createElement('button'); btn.className = 'call-option-btn'; btn.textContent = callStep === 2 ? (item.modal ? item.key + ' modal' : item.key) : item.name; btn.addEventListener('click', () => callSelect(callStep === 2 ? item.key : item.id, callStep === 2 ? item.modal : null)); opts.appendChild(btn); }); } function callSelect(value, modal) { if (callStep === 0) { callInstrumentId = value; callStep = 1; renderCallStep(); } else if (callStep === 1) { callTuningId = value; callStep = 2; renderCallStep(); } else { filters.status = callMode; filters.instrument = callInstrumentId !== null ? String(callInstrumentId) : null; filters.tuning = callTuningId !== null ? String(callTuningId) : null; filters.key = value; filters.modal = value !== null ? modal : null; updateFilterIndicators(); renderTable(); callModal.close(); } } $('btn-call').addEventListener('click', () => openCallModal('callable')); $('btn-learn').addEventListener('click', () => openCallModal('to_learn')); $('call-close').addEventListener('click', () => callModal.close()); $('call-back').addEventListener('click', () => { callStep--; if (callStep === 0) callInstrumentId = null; else if (callStep === 1) callTuningId = null; renderCallStep(); }); // ── 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 ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── Mobile truncation tooltip ─────────────────────────────────── { const tip = $('mob-tooltip'); let timer = null; tbody.addEventListener('click', e => { const el = e.target.closest('.mob-truncatable'); if (!el) return; if (el.scrollWidth <= el.offsetWidth) return; // not truncated clearTimeout(timer); tip.textContent = el.dataset.full; const rect = el.getBoundingClientRect(); tip.style.top = (rect.bottom + 4) + 'px'; tip.style.left = Math.max(8, Math.min(rect.left, window.innerWidth - 220)) + 'px'; tip.hidden = false; timer = setTimeout(() => { tip.hidden = true; }, 2500); e.stopPropagation(); }); document.addEventListener('click', () => { clearTimeout(timer); tip.hidden = true; }); } // 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();