/* ───────────────────────────────────────────────────────────── Repertory – frontend app Plain JS, no build step, no framework. ───────────────────────────────────────────────────────────── */ // ── State ──────────────────────────────────────────────────── let allTunes = []; let allSources = []; let allMusicians = []; let allInstruments = []; let allTunings = []; let sortCol = 'name'; let sortDir = 'asc'; // editing state let editingTuneId = null; // null = add mode let editingEntryId = null; // null = no existing instrument entry // notes modal state let notesTuneId = null; let notesEntryId = null; let unlocked = false; // ── DOM refs ───────────────────────────────────────────────── const $ = id => document.getElementById(id); const $$ = sel => document.querySelectorAll(sel); const statusEl = $('status'); const tbody = $('tune-tbody'); const emptyMsg = $('empty-msg'); const tuneModal = $('tune-modal'); const notesModal = $('notes-modal'); const tuneForm = $('tune-form'); const refsList = $('refs-list'); const btnUnlock = $('btn-unlock'); const btnAddTune = $('btn-add-tune'); const sourceInput = $('source-input'); const sourceDatalist = $('source-list'); const musicianDatalist = $('musician-list'); const instrumentSelect = $('instrument-select'); const tuningDatalist = $('tuning-list'); const btnDeleteTune = $('btn-delete-tune'); const btnEditNotes = $('btn-edit-notes'); // ── Config ─────────────────────────────────────────────────── const _cfg = window.REPERTORY_CONFIG || {}; function getApiKey() { return _cfg.apiKey || sessionStorage.getItem('apiKey') || ''; } // ── Unlock ─────────────────────────────────────────────────── if (getApiKey()) setUnlocked(true); btnUnlock.addEventListener('click', () => { const key = prompt('API key:'); if (!key) return; sessionStorage.setItem('apiKey', key); if (!_cfg.apiUrl) { const url = prompt('API URL (e.g. https://your-server:5000):'); if (url) sessionStorage.setItem('apiUrl', url.replace(/\/$/, '')); } setUnlocked(true); }); function setUnlocked(val) { unlocked = val; btnUnlock.hidden = val; btnAddTune.hidden = !val; // Show/hide edit column header and edit buttons const editHeader = document.querySelector('.edit-col'); if (editHeader) editHeader.hidden = !val; $$('.btn-edit-row').forEach(b => { b.hidden = !val; }); $$('.note-edit-controls').forEach(el => { el.hidden = !val; }); $('btn-add-tune-note').hidden = !val; $('btn-add-inst-note').hidden = !val; } // ── API helpers ─────────────────────────────────────────────── function resolvedApiUrl() { return (_cfg.apiUrl || sessionStorage.getItem('apiUrl') || '').replace(/\/$/, ''); } async function apiFetch(path, options = {}) { const url = resolvedApiUrl() + path; const headers = { 'Content-Type': 'application/json', ...(options.headers || {}) }; if (options.method && options.method !== 'GET') { headers['X-API-Key'] = getApiKey(); } const res = await fetch(url, { ...options, headers }); if (!res.ok) { const text = await res.text(); throw new Error(`${res.status} ${res.statusText}: ${text}`); } return res.json(); } function setStatus(msg, isError = false) { statusEl.textContent = msg; statusEl.style.color = isError ? '#eb5757' : '#6fcf97'; } // ── Load ────────────────────────────────────────────────────── async function loadAll() { setStatus('Loading…'); try { [allTunes, allSources, allMusicians, allInstruments, allTunings] = await Promise.all([ apiFetch('/tunes/'), apiFetch('/sources/'), apiFetch('/musicians/'), apiFetch('/instruments'), apiFetch('/tunings/'), ]); populateFilterOptions(); populateDatalist(sourceDatalist, allSources); populateDatalist(musicianDatalist, allMusicians); populateDatalist(tuningDatalist, allTunings); populateInstrumentSelect(); renderTable(); setStatus(`${allTunes.length} tunes loaded`); } catch (e) { setStatus(e.message, true); } } function populateDatalist(datalist, items) { datalist.innerHTML = ''; items.forEach(item => { const opt = document.createElement('option'); opt.value = item.name; datalist.appendChild(opt); }); } function populateInstrumentSelect(selectedId = null) { instrumentSelect.innerHTML = ''; allInstruments.forEach(inst => { const opt = document.createElement('option'); opt.value = inst.id; opt.textContent = inst.name; if (selectedId && inst.id === selectedId) opt.selected = true; instrumentSelect.appendChild(opt); }); } function populateFilterOptions() { // Nothing to populate in DOM — popover dropdowns are built on demand // from allSources, allInstruments, allTunings in buildDropdownOptions() } // ── Quick-add ───────────────────────────────────────────────── async function quickAdd(endpoint, promptText, list, datalist, onDone) { const name = prompt(promptText); if (!name) return null; try { const item = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ name }) }); list.push(item); list.sort((a, b) => a.name.localeCompare(b.name)); if (datalist) populateDatalist(datalist, list); if (onDone) onDone(item); return item; } catch (e) { setStatus(e.message, true); return null; } } $('btn-quick-add-source').addEventListener('click', async () => { await quickAdd('/sources/', 'New source name:', allSources, sourceDatalist, item => { sourceInput.value = item.name; populateFilterOptions(); }); }); $('btn-quick-add-musician').addEventListener('click', async () => { await quickAdd('/musicians/', 'New musician name:', allMusicians, musicianDatalist, item => { tuneForm.elements.inst_learned_from.value = item.name; }); }); $('btn-quick-add-tuning').addEventListener('click', async () => { await quickAdd('/tunings/', 'New tuning name:', allTunings, tuningDatalist, item => { tuneForm.elements.inst_tuning.value = item.name; populateFilterOptions(); }); }); // ── Resolve name → id ───────────────────────────────────────── function resolveSourceId(name) { if (!name) return null; return allSources.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null; } function resolveMusicianId(name) { if (!name) return null; return allMusicians.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null; } function resolveTuningId(name) { if (!name) return null; return allTunings.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null; } // ── Rows: one per instrument entry ──────────────────────────── // For tunes with no instrument entries, emit one row with empty instrument cols function buildRows(tunes) { const rows = []; for (const tune of tunes) { if (tune.instruments.length === 0) { rows.push({ tune, entry: null }); } else { for (const entry of tune.instruments) { rows.push({ tune, entry }); } } } return rows; } // ── Filters (popover per column) ───────────────────────────── const filters = { key: null, source: null, modal: null, instrument: null, tuning: null, callable: null, review: null, to_learn: null, difficulty: null, }; const popover = $('filter-popover'); const popoverInner = $('filter-popover-inner'); let activeFilterKey = null; const BOOL_FILTERS = new Set(['modal', 'callable', 'review', 'to_learn']); const DROPDOWN_FILTERS = new Set(['source', 'instrument', 'tuning', 'difficulty']); function openFilterPopover(key, thEl) { if (activeFilterKey === key) { closeFilterPopover(); return; } activeFilterKey = key; popoverInner.innerHTML = ''; if (BOOL_FILTERS.has(key)) { const label = document.createElement('label'); label.className = 'popover-checkbox-row'; const cb = document.createElement('input'); cb.type = 'checkbox'; if (filters[key] === true) { cb.checked = true; cb.indeterminate = false; } else if (filters[key] === false) { cb.checked = false; cb.indeterminate = true; } else { cb.checked = false; cb.indeterminate = false; } const names = { modal: 'Modal', callable: 'Callable', review: 'Needs review', to_learn: 'To learn' }; label.appendChild(cb); label.appendChild(document.createTextNode(names[key] ?? key)); popoverInner.appendChild(label); cb.addEventListener('change', () => { if (filters[key] === null) { filters[key] = true; cb.checked = true; cb.indeterminate = false; } else if (filters[key] === true) { filters[key] = false; cb.checked = false; cb.indeterminate = true; } else { filters[key] = null; cb.checked = false; cb.indeterminate = false; } updateFilterIndicators(); renderTable(); }); } else if (DROPDOWN_FILTERS.has(key)) { const sel = document.createElement('select'); buildDropdownOptions(key).forEach(([val, lbl]) => { const opt = document.createElement('option'); opt.value = val; opt.textContent = lbl; if (val === (filters[key] ?? '')) opt.selected = true; sel.appendChild(opt); }); popoverInner.appendChild(sel); sel.addEventListener('change', () => { filters[key] = sel.value || null; updateFilterIndicators(); renderTable(); }); } else { // key — plain text const inp = document.createElement('input'); inp.type = 'text'; inp.placeholder = 'Filter…'; inp.value = filters[key] ?? ''; popoverInner.appendChild(inp); inp.addEventListener('input', () => { filters[key] = inp.value || null; updateFilterIndicators(); renderTable(); }); requestAnimationFrame(() => inp.focus()); } const clr = document.createElement('button'); clr.className = 'popover-clear'; clr.textContent = 'Clear'; clr.addEventListener('click', () => { filters[key] = null; updateFilterIndicators(); renderTable(); closeFilterPopover(); }); popoverInner.appendChild(clr); const rect = thEl.getBoundingClientRect(); popover.hidden = false; popover.style.top = `${rect.bottom + 4}px`; popover.style.left = `${Math.min(rect.left, window.innerWidth - 200)}px`; } function closeFilterPopover() { popover.hidden = true; activeFilterKey = null; } function buildDropdownOptions(key) { const none = [['', '— all —']]; if (key === 'source') return [...none, ...allSources.map(s => [String(s.id), s.name])]; if (key === 'instrument') return [...none, ...allInstruments.map(i => [String(i.id), i.name])]; if (key === 'tuning') return [...none, ...allTunings.map(t => [String(t.id), t.name])]; if (key === 'difficulty') return [...none, ['easy', 'Easy'], ['hard', 'Hard']]; return none; } function updateFilterIndicators() { $$('th[data-filter]').forEach(th => { th.classList.toggle('filter-active', filters[th.dataset.filter] !== null); }); const anyActive = Object.values(filters).some(v => v !== null) || $('filter-search').value; $('btn-clear-filters').hidden = !anyActive; } $$('th[data-filter]').forEach(th => { th.addEventListener('click', e => { e.stopPropagation(); openFilterPopover(th.dataset.filter, th); }); }); document.addEventListener('click', e => { if (!popover.hidden && !popover.contains(e.target)) closeFilterPopover(); }); document.addEventListener('keydown', e => { if (e.key === 'Escape') closeFilterPopover(); }); $('filter-search').addEventListener('input', () => { updateFilterIndicators(); renderTable(); }); $('btn-clear-filters').addEventListener('click', () => { Object.keys(filters).forEach(k => { filters[k] = null; }); $('filter-search').value = ''; updateFilterIndicators(); renderTable(); closeFilterPopover(); }); // ── Match function ──────────────────────────────────────────── function rowMatchesFilters(tune, entry) { const search = $('filter-search').value.toLowerCase(); if (search && !tune.name?.toLowerCase().includes(search)) return false; if (filters.key && tune.key?.toLowerCase() !== filters.key.toLowerCase()) return false; if (filters.source && String(tune.source_id) !== filters.source) return false; if (filters.modal !== null && tune.modal !== filters.modal) return false; if (filters.instrument && (!entry || String(entry.instrument_id) !== filters.instrument)) return false; if (entry) { if (filters.tuning && String(entry.tuning_id) !== filters.tuning) return false; if (filters.callable !== null && entry.callable !== filters.callable) return false; if (filters.review !== null && entry.review !== filters.review) return false; if (filters.to_learn !== null && entry.to_learn !== filters.to_learn) return false; if (filters.difficulty && entry.difficulty?.toLowerCase() !== filters.difficulty) return false; } return true; } // ── Sorting ─────────────────────────────────────────────────── function sortTunes(tunes) { return [...tunes].sort((a, b) => { let av = a[sortCol] ?? ''; let bv = b[sortCol] ?? ''; if (typeof av === 'string') av = av.toLowerCase(); if (typeof bv === 'string') bv = bv.toLowerCase(); if (av < bv) return sortDir === 'asc' ? -1 : 1; if (av > bv) return sortDir === 'asc' ? 1 : -1; return 0; }); } $$('#tune-table thead th[data-col]').forEach(th => { th.addEventListener('click', () => { const col = th.dataset.col; sortDir = sortCol === col && sortDir === 'asc' ? 'desc' : 'asc'; sortCol = col; $$('#tune-table thead th').forEach(t => t.classList.remove('sort-asc', 'sort-desc')); th.classList.add(sortDir === 'asc' ? 'sort-asc' : 'sort-desc'); renderTable(); }); }); // ── Render ──────────────────────────────────────────────────── function renderTable() { const sorted = sortTunes(allTunes); const rows = buildRows(sorted).filter(({ tune, entry }) => rowMatchesFilters(tune, entry)); tbody.innerHTML = ''; emptyMsg.hidden = rows.length > 0; rows.forEach(({ tune, entry }) => { const hasNotes = (tune.notes?.length > 0) || (entry?.notes?.length > 0); const tr = document.createElement('tr'); tr.innerHTML = ` ${esc(tune.name ?? '')} ${esc(tune.key ?? '')} ${esc(tune.source?.name ?? '')} ${tune.modal ? 'Yes' : ''} ${esc(entry?.instrument_name ?? '')} ${esc(entry?.tuning ?? '')} ${pillBool(entry?.callable)} ${pillBool(entry?.review, true)} ${pillBool(entry?.to_learn, false, true)} ${hasNotes ? `` : ''} `; tbody.appendChild(tr); }); tbody.querySelectorAll('.btn-notes').forEach(b => b.addEventListener('click', () => openNotesModal(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null))); tbody.querySelectorAll('.btn-edit-row').forEach(b => b.addEventListener('click', () => openEdit(+b.dataset.tuneId, b.dataset.entryId ? +b.dataset.entryId : null))); } function pillBool(val, warnIfTrue = false, neutralIfTrue = false) { if (val === null || val === undefined) return ''; if (val === true) { if (neutralIfTrue || warnIfTrue) return 'Yes'; return 'Yes'; } return 'No'; } // ── Add / Edit modal ────────────────────────────────────────── btnAddTune.addEventListener('click', openAdd); function openAdd() { editingTuneId = null; editingEntryId = null; $('modal-title').textContent = 'Add tune'; tuneForm.reset(); refsList.innerHTML = ''; sourceInput.value = ''; populateInstrumentSelect(); instrumentSelect.value = ''; btnDeleteTune.hidden = true; btnEditNotes.hidden = true; tuneModal.showModal(); } function openEdit(tuneId, entryId) { editingTuneId = tuneId; editingEntryId = entryId; const tune = allTunes.find(t => t.id === tuneId); const entry = entryId ? tune?.instruments.find(e => e.id === entryId) : null; $('modal-title').textContent = `Edit: ${tune?.name ?? ''}`; tuneForm.reset(); tuneForm.elements.name.value = tune?.name ?? ''; tuneForm.elements.key.value = tune?.key ?? ''; tuneForm.elements.modal.checked = !!tune?.modal; sourceInput.value = tune?.source?.name ?? ''; // Instrument entry populateInstrumentSelect(entry?.instrument_id ?? null); if (entry) { tuneForm.elements.inst_tuning.value = entry.tuning ?? ''; tuneForm.elements.inst_learned_from.value = entry.learned_from ?? ''; tuneForm.elements.inst_date_learned.value = entry.date_learned ?? ''; tuneForm.elements.inst_callable.checked = !!entry.callable; tuneForm.elements.inst_review.checked = !!entry.review; tuneForm.elements.inst_to_learn.checked = !!entry.to_learn; tuneForm.elements.inst_difficulty.value = entry.difficulty ?? ''; } // References refsList.innerHTML = ''; (tune?.references ?? []).forEach(r => addRefRow(r.link, r.site, r.id)); btnDeleteTune.hidden = false; btnEditNotes.hidden = false; tuneModal.showModal(); } // ── Reference rows ──────────────────────────────────────────── $('btn-add-ref').addEventListener('click', () => addRefRow()); function addRefRow(link = '', site = '', refId = null) { const row = document.createElement('div'); row.className = 'ref-row'; row.dataset.refId = refId ?? ''; row.innerHTML = ` `; row.querySelector('.remove-btn').addEventListener('click', () => row.remove()); refsList.appendChild(row); } // ── Save ────────────────────────────────────────────────────── $('btn-save-tune').addEventListener('click', saveTune); $('btn-cancel').addEventListener('click', () => tuneModal.close()); async function saveTune() { const f = tuneForm.elements; const tuneBody = { name: f.name.value || null, key: f.key.value || null, modal: f.modal.checked, source_id: resolveSourceId(sourceInput.value), }; const instId = instrumentSelect.value ? parseInt(instrumentSelect.value) : null; const instBody = instId ? { instrument_id: instId, tuning_id: resolveTuningId(f.inst_tuning.value), learned_from_id: resolveMusicianId(f.inst_learned_from.value), date_learned: f.inst_date_learned.value || null, callable: f.inst_callable.checked, review: f.inst_review.checked, to_learn: f.inst_to_learn.checked, difficulty: f.inst_difficulty.value || null, } : null; const references = [...refsList.querySelectorAll('.ref-row')] .map(row => ({ link: row.querySelector('.ref-link').value || null, site: row.querySelector('.ref-site').value || null, })) .filter(r => r.link || r.site); try { let tuneId = editingTuneId; if (tuneId === null) { // Create tune const created = await apiFetch('/tunes/', { method: 'POST', body: JSON.stringify({ ...tuneBody, references }), }); tuneId = created.id; // Add instrument entry if selected if (instBody) { await apiFetch(`/tunes/${tuneId}/instruments`, { method: 'POST', body: JSON.stringify(instBody), }); } } else { // Update tune core await apiFetch(`/tunes/${tuneId}`, { method: 'PATCH', body: JSON.stringify(tuneBody) }); // Update or create instrument entry if (instBody) { if (editingEntryId) { await apiFetch(`/tunes/${tuneId}/instruments/${editingEntryId}`, { method: 'PATCH', body: JSON.stringify(instBody), }); } else { await apiFetch(`/tunes/${tuneId}/instruments`, { method: 'POST', body: JSON.stringify(instBody), }); } } } tuneModal.close(); await loadAll(); } catch (e) { setStatus(e.message, true); } } // ── Delete ──────────────────────────────────────────────────── btnEditNotes.addEventListener('click', () => { tuneModal.close(); openNotesModal(editingTuneId, editingEntryId); }); btnDeleteTune.addEventListener('click', async () => { const tune = allTunes.find(t => t.id === editingTuneId); if (!confirm(`Delete "${tune?.name ?? editingTuneId}" and all its data?`)) return; try { await apiFetch(`/tunes/${editingTuneId}`, { method: 'DELETE' }); tuneModal.close(); await loadAll(); } catch (e) { setStatus(e.message, true); } }); // ── Notes modal ─────────────────────────────────────────────── async function openNotesModal(tuneId, entryId) { notesTuneId = tuneId; notesEntryId = entryId; const tune = allTunes.find(t => t.id === tuneId); const entry = entryId ? tune?.instruments.find(e => e.id === entryId) : null; $('notes-modal-title').textContent = `Notes — ${tune?.name ?? ''}`; $('inst-notes-heading').textContent = entry ? `${entry.instrument_name} notes` : 'Instrument notes'; $('inst-notes-section').hidden = !entry; renderTuneNotes(tune?.notes ?? []); renderInstNotes(entry?.notes ?? []); $('btn-add-tune-note').hidden = !unlocked; $('btn-add-inst-note').hidden = !unlocked || !entry; notesModal.showModal(); } function renderTuneNotes(notes) { const list = $('tune-notes-list'); list.innerHTML = ''; notes.forEach(n => { const row = document.createElement('div'); row.className = 'note-row'; row.innerHTML = `${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()); // ── Utility ─────────────────────────────────────────────────── function esc(str) { if (str === null || str === undefined) return ''; return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } // ── Boot ───────────────────────────────────────────────────── loadAll();