/* ───────────────────────────────────────────────────────────── 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 = `