1564 lines
60 KiB
JavaScript
1564 lines
60 KiB
JavaScript
/* ─────────────────────────────────────────────────────────────
|
||
Repertory – frontend app
|
||
Plain JS, no build step, no framework.
|
||
───────────────────────────────────────────────────────────── */
|
||
|
||
// ── State ────────────────────────────────────────────────────
|
||
let allTunes = [];
|
||
let allSources = [];
|
||
let allMusicians = [];
|
||
let allInstruments = [];
|
||
let allTunings = [];
|
||
let allReferenceSites = [];
|
||
let allTerms = [];
|
||
let sortCol = 'name';
|
||
let sortDir = 'asc';
|
||
|
||
// editing state
|
||
let editingTuneId = null; // null = add mode
|
||
let editingEntryId = null; // null = no existing instrument entry
|
||
let editingOriginalRefIds = []; // ref IDs present when edit modal opened
|
||
|
||
// notes modal state
|
||
let notesTuneId = null;
|
||
let notesEntryId = null;
|
||
let noteEditorState = null; // { type: 'tune'|'inst', noteId: null|number }
|
||
|
||
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 termDatalist = $('term-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, allTerms] = await Promise.all([
|
||
apiFetch('/tunes/'),
|
||
apiFetch('/sources/'),
|
||
apiFetch('/musicians/'),
|
||
apiFetch('/instruments'),
|
||
apiFetch('/tunings/'),
|
||
apiFetch('/reference_sites/'),
|
||
apiFetch('/terms/'),
|
||
]);
|
||
populateFilterOptions();
|
||
populateDatalist(sourceDatalist, allSources);
|
||
populateDatalist(musicianDatalist, allMusicians);
|
||
populateDatalist(tuningDatalist, allTunings);
|
||
populateDatalist(termDatalist, allTerms);
|
||
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 = '<option value="">— none —</option>';
|
||
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();
|
||
});
|
||
});
|
||
|
||
$('btn-quick-add-term').addEventListener('click', async () => {
|
||
await quickAdd('/terms/', 'New term name:', allTerms, termDatalist, item => {
|
||
tuneForm.elements.inst_term.value = item.name;
|
||
});
|
||
});
|
||
|
||
$('btn-quick-add-instrument').addEventListener('click', async () => {
|
||
const item = await quickAdd('/instruments', 'New instrument name:', allInstruments, null);
|
||
if (item) {
|
||
const opt = document.createElement('option');
|
||
opt.value = item.id;
|
||
opt.textContent = item.name;
|
||
instrumentSelect.appendChild(opt);
|
||
instrumentSelect.value = item.id;
|
||
}
|
||
});
|
||
|
||
// ── 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;
|
||
}
|
||
|
||
function resolveTermId(name) {
|
||
if (!name) return null;
|
||
return allTerms.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 merged into key display; filter still works programmatically
|
||
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.has_notes ?? false;
|
||
|
||
// ── 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 ? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>` : '',
|
||
(tune.reference_count ?? 0) > 0 ? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}">🔗</button>` : '',
|
||
`<button class="action-btn btn-edit-row" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}" ${unlocked ? '' : 'hidden'}>✏️</button>`,
|
||
].join('');
|
||
|
||
const tr = document.createElement('tr');
|
||
tr.innerHTML = `
|
||
<td><span class="cell-trunc cell-name-content mob-truncatable" data-full="${esc(nameRaw)}" title="${esc(nameRaw)}">${esc(nameRaw)}</span></td>
|
||
<td><span class="cell-trunc cell-source-content mob-truncatable" data-full="${esc(sourceRaw)}" title="${esc(sourceRaw)}">${esc(sourceRaw)}</span></td>
|
||
<td>${esc(keyStr)}</td>
|
||
<td><span class="cell-trunc cell-inst-content" title="${esc(entry?.instrument_name ?? '')}">${esc(entry?.instrument_name ?? '')}</span></td>
|
||
<td><span class="cell-trunc cell-tuning-content" title="${esc(entry?.tuning ?? '')}">${esc(entry?.tuning ?? '')}</span></td>
|
||
<td>${pillStatus(entry?.status)}</td>
|
||
<td>${hasNotes
|
||
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
|
||
: ''}</td>
|
||
<td>${(tune.reference_count ?? 0) > 0
|
||
? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}" title="References">🔗</button>`
|
||
: ''}</td>
|
||
<td><button class="action-btn btn-edit-row"
|
||
data-tune-id="${tune.id}"
|
||
data-entry-id="${entry?.id ?? ''}"
|
||
title="Edit"
|
||
${unlocked ? '' : 'hidden'}>✏️</button></td>
|
||
<td class="mobile-row" colspan="99">
|
||
<div class="mob-line1">
|
||
<div class="mob-name-wrap">
|
||
<span class="mob-name mob-truncatable" data-full="${esc(nameRaw)}" title="${esc(nameRaw)}">${esc(nameRaw)}</span>
|
||
${sourceRaw ? `<span class="mob-source mob-truncatable" data-full="${esc(sourceRaw)}" title="${esc(sourceRaw)}">(${esc(sourceRaw)})</span>` : ''}
|
||
</div>
|
||
<span class="mob-status">${mobPills}</span>
|
||
</div>
|
||
<div class="mob-line2">
|
||
<span class="mob-meta mob-truncatable" data-full="${esc(metaRaw)}" title="${esc(metaRaw)}">${esc(metaRaw)}</span>
|
||
<span class="mob-actions">${mobActions}</span>
|
||
</div>
|
||
</td>`;
|
||
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 '<span class="pill" style="background:#222">—</span>';
|
||
if (val === true) {
|
||
if (neutralIfTrue || warnIfTrue) return '<span class="pill pill-warn">Yes</span>';
|
||
return '<span class="pill pill-yes">Yes</span>';
|
||
}
|
||
return '<span class="pill pill-no">No</span>';
|
||
}
|
||
|
||
function pillStatus(status, extraClass = '') {
|
||
const c = extraClass ? ' ' + extraClass : '';
|
||
if (status === 'callable') return `<span class="pill pill-yes${c}">Callable</span>`;
|
||
if (status === 'review') return `<span class="pill pill-warn${c}">Needs Review</span>`;
|
||
if (status === 'to_learn') return `<span class="pill pill-to-learn${c}">To Learn</span>`;
|
||
return `<span class="pill pill-dim${c}">—</span>`;
|
||
}
|
||
|
||
// ── Add / Edit modal ──────────────────────────────────────────
|
||
btnAddTune.addEventListener('click', openAdd);
|
||
|
||
function openAdd() {
|
||
editingTuneId = null;
|
||
editingEntryId = null;
|
||
editingOriginalRefIds = [];
|
||
setTuneError('');
|
||
$('modal-title').textContent = 'Add tune';
|
||
tuneForm.reset();
|
||
refsList.innerHTML = '';
|
||
sourceInput.value = '';
|
||
populateInstrumentSelect();
|
||
instrumentSelect.value = '';
|
||
btnDeleteTune.hidden = true;
|
||
btnEditNotes.hidden = true;
|
||
tuneModal.showModal();
|
||
}
|
||
|
||
async function openEdit(tuneId, entryId) {
|
||
editingTuneId = tuneId;
|
||
editingEntryId = entryId;
|
||
|
||
// Open modal immediately — name from slim data gives instant feedback
|
||
const slimTune = allTunes.find(t => t.id === tuneId);
|
||
$('modal-title').textContent = `Edit: ${slimTune?.name ?? ''}`;
|
||
tuneForm.reset();
|
||
refsList.innerHTML = '';
|
||
btnDeleteTune.hidden = false;
|
||
btnEditNotes.hidden = false;
|
||
tuneModal.showModal();
|
||
|
||
// Fetch full tune for references and confirmed field values
|
||
let fullTune;
|
||
try {
|
||
fullTune = await apiFetch(`/tunes/${tuneId}`);
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
tuneModal.close();
|
||
return;
|
||
}
|
||
|
||
const entry = entryId ? fullTune.instruments.find(e => e.id === entryId) : null;
|
||
|
||
$('modal-title').textContent = `Edit: ${fullTune.name ?? ''}`;
|
||
tuneForm.elements.name.value = fullTune.name ?? '';
|
||
tuneForm.elements.key.value = fullTune.key ?? '';
|
||
tuneForm.elements.modal.checked = !!fullTune.modal;
|
||
sourceInput.value = fullTune.source?.name ?? '';
|
||
|
||
populateInstrumentSelect(entry?.instrument_id ?? null);
|
||
if (entry) {
|
||
tuneForm.elements.inst_tuning.value = entry.tuning ?? '';
|
||
tuneForm.elements.inst_term.value = entry.term ?? '';
|
||
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 ?? '';
|
||
}
|
||
|
||
editingOriginalRefIds = (fullTune.references ?? []).map(r => r.id);
|
||
(fullTune.references ?? []).forEach(r => addRefRow(r.link, r.site?.name ?? '', r.id, r.site_id, r.musicians ?? []));
|
||
}
|
||
|
||
// ── Musician chip picker (shared by ref rows + bulk form) ──────
|
||
function makeMusicianPicker(initialMusicians = []) {
|
||
let musicians = initialMusicians.map(m => ({ id: m.id, name: m.name }));
|
||
|
||
const el = document.createElement('div');
|
||
el.className = 'ref-musicians-row';
|
||
el.innerHTML = `<span class="ref-musician-chips"></span>
|
||
<span class="autocomplete-row">
|
||
<input type="text" placeholder="Musician" class="ref-musician-input"
|
||
list="musician-list" autocomplete="off" />
|
||
<button type="button" class="quick-add-btn btn-add-ref-musician" title="Add musician">+</button>
|
||
</span>`;
|
||
|
||
const chipsEl = el.querySelector('.ref-musician-chips');
|
||
const input = el.querySelector('.ref-musician-input');
|
||
const btnPlus = el.querySelector('.btn-add-ref-musician');
|
||
|
||
function refresh() {
|
||
chipsEl.innerHTML = '';
|
||
musicians.forEach(m => {
|
||
const chip = document.createElement('span');
|
||
chip.className = 'musician-chip';
|
||
chip.innerHTML = `${esc(m.name)}<button type="button" class="chip-remove" title="Remove">✕</button>`;
|
||
chip.querySelector('.chip-remove').addEventListener('click', () => {
|
||
musicians = musicians.filter(x => x.id !== m.id);
|
||
refresh();
|
||
});
|
||
chipsEl.appendChild(chip);
|
||
});
|
||
}
|
||
|
||
function addByName(name) {
|
||
name = name.trim();
|
||
if (!name) return false;
|
||
const found = allMusicians.find(m => m.name.toLowerCase() === name.toLowerCase());
|
||
if (!found || musicians.some(m => m.id === found.id)) return false;
|
||
musicians.push({ id: found.id, name: found.name });
|
||
input.value = '';
|
||
refresh();
|
||
return true;
|
||
}
|
||
|
||
attachTabAutocomplete(input, musicianDatalist);
|
||
|
||
input.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') { e.preventDefault(); addByName(input.value); }
|
||
});
|
||
|
||
btnPlus.addEventListener('click', async () => {
|
||
if (addByName(input.value)) return;
|
||
const item = await quickAdd('/musicians/', 'New musician name:', allMusicians, musicianDatalist);
|
||
if (item) { musicians.push({ id: item.id, name: item.name }); refresh(); }
|
||
});
|
||
|
||
refresh();
|
||
|
||
return {
|
||
el,
|
||
getMusicians: () => musicians.map(m => ({ id: m.id })),
|
||
reset: () => { musicians = []; input.value = ''; refresh(); },
|
||
};
|
||
}
|
||
|
||
// ── Reference rows ────────────────────────────────────────────
|
||
$('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 => `<option value="${s.id}"${s.id === siteId ? ' selected' : ''}>${esc(s.name)}</option>`)
|
||
.join('');
|
||
row.innerHTML = `<input type="text" placeholder="URL" value="${esc(link)}" class="ref-link" />
|
||
<span class="autocomplete-row ref-site-wrap">
|
||
<select class="ref-site"><option value="">— site —</option>${siteOptions}</select>
|
||
<button type="button" class="quick-add-btn btn-quick-add-ref-site" title="Add new site">+</button>
|
||
</span>
|
||
<button type="button" class="remove-btn">✕</button>`;
|
||
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', () => { setTuneError(''); tuneModal.close(); });
|
||
|
||
function setTuneError(msg) {
|
||
const el = $('tune-error');
|
||
el.textContent = msg;
|
||
el.hidden = !msg;
|
||
}
|
||
|
||
async function saveTune() {
|
||
const f = tuneForm.elements;
|
||
|
||
// Validate required fields
|
||
const keyVal = f.key.value.trim();
|
||
const tuningVal = f.inst_tuning.value.trim();
|
||
|
||
if (!keyVal) {
|
||
setTuneError('Key is required.');
|
||
f.key.focus();
|
||
return;
|
||
}
|
||
if (!instrumentSelect.value) {
|
||
setTuneError('Instrument is required.');
|
||
instrumentSelect.focus();
|
||
return;
|
||
}
|
||
if (!tuningVal) {
|
||
setTuneError('Tuning is required.');
|
||
f.inst_tuning.focus();
|
||
return;
|
||
}
|
||
if (!resolveTuningId(tuningVal)) {
|
||
setTuneError(`Tuning "${tuningVal}" not found — use + to add it first.`);
|
||
f.inst_tuning.focus();
|
||
return;
|
||
}
|
||
setTuneError('');
|
||
|
||
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),
|
||
term_id: resolveTermId(f.inst_term.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),
|
||
});
|
||
}
|
||
}
|
||
|
||
// Sync references
|
||
const formRows = [...refsList.querySelectorAll('.ref-row')];
|
||
const formRefIds = new Set(formRows.map(r => r.dataset.refId).filter(Boolean));
|
||
|
||
// Delete refs removed from the form
|
||
for (const refId of editingOriginalRefIds) {
|
||
if (!formRefIds.has(String(refId))) {
|
||
await apiFetch(`/tunes/${tuneId}/references/${refId}`, { method: 'DELETE' });
|
||
}
|
||
}
|
||
|
||
// Create new refs; patch existing ones (link + site)
|
||
for (const row of formRows) {
|
||
const refId = row.dataset.refId;
|
||
const link = row.querySelector('.ref-link').value.trim() || null;
|
||
const siteId = row.querySelector('.ref-site').value ? parseInt(row.querySelector('.ref-site').value) : null;
|
||
if (!link && !siteId) continue;
|
||
if (refId) {
|
||
await apiFetch(`/tunes/${tuneId}/references/${refId}`, {
|
||
method: 'PATCH', body: JSON.stringify({ link, site_id: siteId }),
|
||
});
|
||
} else {
|
||
const musicians = row._getMusicians ? row._getMusicians() : [];
|
||
await apiFetch(`/tunes/${tuneId}/references`, {
|
||
method: 'POST', body: JSON.stringify({ link, site_id: siteId, musicians }),
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
// Open immediately with name from slim data
|
||
const slimTune = allTunes.find(t => t.id === tuneId);
|
||
$('notes-modal-title').textContent = `Notes — ${slimTune?.name ?? ''}`;
|
||
$('tune-notes-list').innerHTML = '';
|
||
$('inst-notes-list').innerHTML = '';
|
||
$('inst-notes-section').hidden = !entryId;
|
||
$('btn-add-tune-note').hidden = !unlocked;
|
||
$('btn-add-inst-note').hidden = !unlocked || !entryId;
|
||
notesModal.showModal();
|
||
|
||
// Fetch full tune for note data
|
||
try {
|
||
const fullTune = await apiFetch(`/tunes/${tuneId}`);
|
||
const entry = entryId ? fullTune.instruments.find(e => e.id === entryId) : null;
|
||
|
||
$('inst-notes-heading').textContent = entry ? `${entry.instrument_name} notes` : 'Instrument notes';
|
||
$('inst-notes-section').hidden = !entry;
|
||
$('btn-add-inst-note').hidden = !unlocked || !entry;
|
||
|
||
renderTuneNotes(fullTune.notes ?? []);
|
||
renderInstNotes(entry?.notes ?? []);
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
}
|
||
}
|
||
|
||
function renderTuneNotes(notes) {
|
||
const list = $('tune-notes-list');
|
||
list.innerHTML = '';
|
||
notes.forEach(n => {
|
||
const row = document.createElement('div');
|
||
row.className = 'note-row';
|
||
row.innerHTML = `<span class="note-text">${esc(n.note)}</span>
|
||
<span class="note-edit-controls" ${unlocked ? '' : 'hidden'}>
|
||
<button class="action-btn btn-edit-note" data-id="${n.id}">✏️</button>
|
||
<button class="action-btn btn-delete-note remove-btn" data-id="${n.id}">✕</button>
|
||
</span>`;
|
||
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 = `<span class="note-text">${esc(n.note)}</span>
|
||
<span class="note-edit-controls" ${unlocked ? '' : 'hidden'}>
|
||
<button class="action-btn btn-edit-note" data-id="${n.id}">✏️</button>
|
||
<button class="action-btn btn-delete-note remove-btn" data-id="${n.id}">✕</button>
|
||
</span>`;
|
||
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', () => showNoteEditor('tune', null));
|
||
$('btn-add-inst-note').addEventListener('click', () => showNoteEditor('inst', null));
|
||
|
||
function editTuneNote(n) { showNoteEditor('tune', n.id, n.note); }
|
||
function editInstNote(n) { showNoteEditor('inst', n.id, n.note); }
|
||
|
||
function showNoteEditor(type, noteId, existingText = '') {
|
||
noteEditorState = { type, noteId };
|
||
$('note-editor-title').textContent = noteId ? 'Edit note' : 'Add note';
|
||
$('note-editor-ta').value = existingText;
|
||
$('note-editor-modal').showModal();
|
||
$('note-editor-ta').focus();
|
||
}
|
||
|
||
function hideNoteEditor() {
|
||
noteEditorState = null;
|
||
$('note-editor-ta').value = '';
|
||
$('note-editor-modal').close();
|
||
}
|
||
|
||
$('btn-note-cancel').addEventListener('click', hideNoteEditor);
|
||
|
||
$('note-editor-ta').addEventListener('keydown', e => {
|
||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') $('btn-note-save').click();
|
||
if (e.key === 'Escape') hideNoteEditor();
|
||
});
|
||
|
||
$('btn-note-save').addEventListener('click', async () => {
|
||
if (!noteEditorState) return;
|
||
const text = $('note-editor-ta').value.trim();
|
||
if (!text) return;
|
||
const { type, noteId } = noteEditorState;
|
||
$('btn-note-save').disabled = true;
|
||
try {
|
||
if (type === 'tune') {
|
||
await apiFetch(
|
||
noteId ? `/tunes/${notesTuneId}/notes/${noteId}` : `/tunes/${notesTuneId}/notes`,
|
||
{ method: noteId ? 'PATCH' : 'POST', body: JSON.stringify({ note: text }) }
|
||
);
|
||
} else {
|
||
await apiFetch(
|
||
noteId
|
||
? `/tunes/${notesTuneId}/instruments/${notesEntryId}/notes/${noteId}`
|
||
: `/tunes/${notesTuneId}/instruments/${notesEntryId}/notes`,
|
||
{ method: noteId ? 'PATCH' : 'POST', body: JSON.stringify({ note: text }) }
|
||
);
|
||
}
|
||
hideNoteEditor();
|
||
await reloadNotesModal();
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
} finally {
|
||
$('btn-note-save').disabled = false;
|
||
}
|
||
});
|
||
|
||
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 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() {
|
||
try {
|
||
const fullTune = await apiFetch(`/tunes/${notesTuneId}`);
|
||
const entry = notesEntryId ? fullTune.instruments.find(e => e.id === notesEntryId) : null;
|
||
renderTuneNotes(fullTune.notes ?? []);
|
||
renderInstNotes(entry?.notes ?? []);
|
||
// Keep has_notes in sync so the 📝 button stays accurate
|
||
const idx = allTunes.findIndex(t => t.id === notesTuneId);
|
||
if (idx >= 0) {
|
||
allTunes[idx].has_notes = !!fullTune.notes?.length
|
||
|| fullTune.instruments.some(e => !!e.notes?.length);
|
||
}
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
}
|
||
}
|
||
|
||
$('btn-notes-close').addEventListener('click', () => notesModal.close());
|
||
|
||
// ── References display modal ────────────────────────────────────
|
||
$('btn-refs-modal-close').addEventListener('click', () => refsModal.close());
|
||
|
||
async function openRefsModal(tuneId) {
|
||
const tune = allTunes.find(t => t.id === tuneId);
|
||
$('refs-modal-title').textContent = `References — ${tune?.name ?? ''}`;
|
||
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">Loading…</p>';
|
||
refsModal.showModal();
|
||
|
||
try {
|
||
const refs = await apiFetch(`/tunes/${tuneId}/references`);
|
||
refsModalList.innerHTML = '';
|
||
if (refs.length === 0) {
|
||
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">No references.</p>';
|
||
} else {
|
||
refs.forEach(r => {
|
||
const item = document.createElement('div');
|
||
item.className = 'refs-modal-item';
|
||
const musicians = r.musicians?.map(m => m.name).join(', ') ?? '';
|
||
const siteName = r.site?.name ?? '';
|
||
const meta = [siteName, musicians].filter(Boolean).join(' · ');
|
||
// Use meta as link text; fall back to hostname so raw URL is never shown
|
||
let displayText = meta;
|
||
if (!displayText && r.link) {
|
||
try { displayText = new URL(r.link).hostname.replace(/^www\./, ''); } catch { displayText = 'link'; }
|
||
}
|
||
if (!displayText) displayText = 'link';
|
||
item.innerHTML = `
|
||
<a href="${esc(r.link ?? '')}" target="_blank" rel="noopener" class="ref-link-anchor">${esc(displayText)}</a>`;
|
||
refsModalList.appendChild(item);
|
||
});
|
||
}
|
||
} catch (e) {
|
||
refsModalList.innerHTML = `<p style="color:#eb5757;font-size:.875rem">${esc(e.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
// ── 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-review').addEventListener('click', () => openCallModal('review'));
|
||
$('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-upload-csv').addEventListener('click', () => {
|
||
bulkEditPopover.hidden = true;
|
||
openUploadCsvModal();
|
||
});
|
||
|
||
$('btn-bulk-refs-close').addEventListener('click', () => bulkRefsModal.close());
|
||
|
||
['bulk-refs-search', 'bulk-refs-instrument', 'bulk-refs-source',
|
||
'bulk-refs-has-refs', 'bulk-refs-sort'].forEach(id => {
|
||
const el = $(id);
|
||
el.addEventListener('input', renderBulkRefsList);
|
||
el.addEventListener('change', renderBulkRefsList);
|
||
});
|
||
|
||
function openBulkRefsModal() {
|
||
const instSel = $('bulk-refs-instrument');
|
||
instSel.innerHTML = '<option value="">All instruments</option>';
|
||
allInstruments.forEach(i => {
|
||
const opt = document.createElement('option');
|
||
opt.value = i.id;
|
||
opt.textContent = i.name;
|
||
instSel.appendChild(opt);
|
||
});
|
||
|
||
const srcSel = $('bulk-refs-source');
|
||
srcSel.innerHTML = '<option value="">All sources</option>';
|
||
allSources.forEach(s => {
|
||
const opt = document.createElement('option');
|
||
opt.value = s.id;
|
||
opt.textContent = s.name;
|
||
srcSel.appendChild(opt);
|
||
});
|
||
|
||
renderBulkRefsList();
|
||
bulkRefsModal.showModal();
|
||
}
|
||
|
||
function getBulkRefsFiltered() {
|
||
const search = $('bulk-refs-search').value.toLowerCase();
|
||
const instId = $('bulk-refs-instrument').value;
|
||
const srcId = $('bulk-refs-source').value;
|
||
const hasRefs = $('bulk-refs-has-refs').value;
|
||
const sortKey = $('bulk-refs-sort').value;
|
||
|
||
let tunes = allTunes.filter(t => {
|
||
if (search && !(t.name ?? '').toLowerCase().includes(search)) return false;
|
||
if (srcId && t.source_id !== parseInt(srcId)) return false;
|
||
if (instId && !t.instruments.some(e => e.instrument_id === parseInt(instId))) return false;
|
||
if (hasRefs === 'no' && (t.reference_count ?? 0) > 0) return false;
|
||
if (hasRefs === 'yes' && (t.reference_count ?? 0) === 0) return false;
|
||
return true;
|
||
});
|
||
|
||
tunes = [...tunes].sort((a, b) => {
|
||
if (sortKey === 'newest') {
|
||
const diff = (b.created_at || '') > (a.created_at || '') ? 1
|
||
: (b.created_at || '') < (a.created_at || '') ? -1 : 0;
|
||
return diff !== 0 ? diff : b.id - a.id;
|
||
}
|
||
if (sortKey === 'oldest') {
|
||
const diff = (a.created_at || '') > (b.created_at || '') ? 1
|
||
: (a.created_at || '') < (b.created_at || '') ? -1 : 0;
|
||
return diff !== 0 ? diff : a.id - b.id;
|
||
}
|
||
if (sortKey === 'name_desc') return (b.name ?? '').localeCompare(a.name ?? '');
|
||
return (a.name ?? '').localeCompare(b.name ?? '');
|
||
});
|
||
|
||
return tunes;
|
||
}
|
||
|
||
function renderBulkRefsList() {
|
||
const tunes = getBulkRefsFiltered();
|
||
bulkRefsList.innerHTML = '';
|
||
|
||
if (tunes.length === 0) {
|
||
bulkRefsList.innerHTML = '<p class="bulk-refs-empty">No tunes match the current filters.</p>';
|
||
return;
|
||
}
|
||
|
||
tunes.forEach(tune => {
|
||
const row = document.createElement('div');
|
||
row.className = 'bulk-tune-row';
|
||
row.dataset.tuneId = tune.id;
|
||
|
||
const instruments = tune.instruments.map(e => e.instrument_name).filter(Boolean).join(', ');
|
||
const refCount = tune.reference_count ?? 0;
|
||
const refPill = refCount > 0
|
||
? `<span class="pill pill-yes">${refCount} ref${refCount !== 1 ? 's' : ''}</span>`
|
||
: `<span class="pill" style="background:#222">0</span>`;
|
||
|
||
row.innerHTML = `
|
||
<div class="bulk-tune-main">
|
||
<span class="bulk-tune-name">${esc(tune.name ?? '')}</span>
|
||
<span class="bulk-tune-meta">${[esc(tune.key ?? ''), esc(tune.source?.name ?? '')].filter(Boolean).join(' · ')}</span>
|
||
<span class="bulk-tune-instruments">${esc(instruments)}</span>
|
||
<span class="bulk-tune-refs">${refPill}</span>
|
||
<button class="btn-bulk-add-ref action-btn">+ ref</button>
|
||
</div>
|
||
<div class="bulk-ref-form" hidden>
|
||
<input type="text" class="bulk-ref-url" placeholder="URL" />
|
||
<span class="autocomplete-row">
|
||
<select class="bulk-ref-site"><option value="">— site —</option>${allReferenceSites.map(s => `<option value="${s.id}">${esc(s.name)}</option>`).join('')}</select>
|
||
<button class="btn-bulk-quick-add-site quick-add-btn" type="button" title="Add new site">+</button>
|
||
</span>
|
||
<button class="btn-bulk-ref-save">Add</button>
|
||
<button class="btn-bulk-ref-cancel remove-btn">✕</button>
|
||
<div class="bulk-ref-musicians-mount"></div>
|
||
</div>`;
|
||
|
||
const form = row.querySelector('.bulk-ref-form');
|
||
const urlInput = row.querySelector('.bulk-ref-url');
|
||
const siteSel = row.querySelector('.bulk-ref-site');
|
||
const btnAdd = row.querySelector('.btn-bulk-add-ref');
|
||
const btnSave = row.querySelector('.btn-bulk-ref-save');
|
||
const btnCancel = row.querySelector('.btn-bulk-ref-cancel');
|
||
const btnQuickAddSite = row.querySelector('.btn-bulk-quick-add-site');
|
||
const picker = makeMusicianPicker();
|
||
row.querySelector('.bulk-ref-musicians-mount').appendChild(picker.el);
|
||
|
||
btnAdd.addEventListener('click', () => {
|
||
form.hidden = !form.hidden;
|
||
if (!form.hidden) urlInput.focus();
|
||
});
|
||
|
||
btnCancel.addEventListener('click', () => {
|
||
form.hidden = true;
|
||
urlInput.value = '';
|
||
siteSel.value = '';
|
||
picker.reset();
|
||
});
|
||
|
||
btnQuickAddSite.addEventListener('click', async () => {
|
||
const item = await quickAdd('/reference_sites/', 'New site name:', allReferenceSites, null);
|
||
if (item) {
|
||
// Add the new option to every open site select in the list
|
||
bulkRefsList.querySelectorAll('.bulk-ref-site').forEach(sel => {
|
||
const opt = document.createElement('option');
|
||
opt.value = item.id;
|
||
opt.textContent = item.name;
|
||
sel.appendChild(opt);
|
||
});
|
||
siteSel.value = item.id;
|
||
}
|
||
});
|
||
|
||
const submitRef = async () => {
|
||
const link = urlInput.value.trim();
|
||
const siteId = siteSel.value ? parseInt(siteSel.value) : null;
|
||
const musicians = picker.getMusicians();
|
||
if (!link && !siteId) return;
|
||
btnSave.disabled = true;
|
||
try {
|
||
await apiFetch(`/tunes/${tune.id}/references`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({ link: link || null, site_id: siteId, musicians }),
|
||
});
|
||
await loadAll();
|
||
renderBulkRefsList();
|
||
} catch (e) {
|
||
setStatus(e.message, true);
|
||
btnSave.disabled = false;
|
||
}
|
||
};
|
||
|
||
btnSave.addEventListener('click', submitRef);
|
||
urlInput.addEventListener('keydown', e => {
|
||
if (e.key === 'Enter') submitRef();
|
||
if (e.key === 'Escape') btnCancel.click();
|
||
});
|
||
|
||
bulkRefsList.appendChild(row);
|
||
});
|
||
}
|
||
|
||
// ── Utility ───────────────────────────────────────────────────
|
||
function esc(str) {
|
||
if (str === null || str === undefined) return '';
|
||
return String(str)
|
||
.replace(/&/g, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"');
|
||
}
|
||
|
||
// ── CSV upload ────────────────────────────────────────────────
|
||
const csvFileInput = $('csv-file-input');
|
||
|
||
csvFileInput.addEventListener('change', () => {
|
||
$('btn-csv-import').disabled = !csvFileInput.files.length;
|
||
$('csv-results').hidden = true;
|
||
$('csv-results').innerHTML = '';
|
||
});
|
||
|
||
$('btn-csv-cancel').addEventListener('click', () => $('upload-csv-modal').close());
|
||
|
||
$('btn-csv-import').addEventListener('click', async () => {
|
||
const file = csvFileInput.files[0];
|
||
if (!file) return;
|
||
$('btn-csv-import').disabled = true;
|
||
$('btn-csv-cancel').textContent = 'Close';
|
||
const resultsEl = $('csv-results');
|
||
resultsEl.hidden = false;
|
||
resultsEl.innerHTML = '<p class="csv-status">Importing…</p>';
|
||
try {
|
||
const text = await file.text();
|
||
const rows = parseCSV(text);
|
||
if (!rows.length) {
|
||
resultsEl.innerHTML = '<p class="csv-error">No data rows found.</p>';
|
||
return;
|
||
}
|
||
const results = await importCSVRows(rows);
|
||
await loadAll();
|
||
let html = `<p class="csv-ok">✓ ${results.created} row${results.created !== 1 ? 's' : ''} imported.</p>`;
|
||
if (results.skipped) html += `<p class="csv-status">${results.skipped} skipped (no name).</p>`;
|
||
if (results.errors.length) {
|
||
html += `<p class="csv-error">${results.errors.length} error${results.errors.length !== 1 ? 's' : ''}:</p><ul class="csv-errors">`;
|
||
results.errors.forEach(e => { html += `<li>${esc(e.name)}: ${esc(e.error)}</li>`; });
|
||
html += '</ul>';
|
||
}
|
||
resultsEl.innerHTML = html;
|
||
} catch (e) {
|
||
resultsEl.innerHTML = `<p class="csv-error">Failed: ${esc(e.message)}</p>`;
|
||
$('btn-csv-import').disabled = false;
|
||
}
|
||
});
|
||
|
||
function openUploadCsvModal() {
|
||
csvFileInput.value = '';
|
||
$('btn-csv-import').disabled = true;
|
||
$('btn-csv-cancel').textContent = 'Cancel';
|
||
$('csv-results').hidden = true;
|
||
$('csv-results').innerHTML = '';
|
||
$('csv-format-details').open = true;
|
||
$('upload-csv-modal').showModal();
|
||
}
|
||
|
||
function parseCSVLine(line) {
|
||
const result = [];
|
||
let current = '', inQuotes = false;
|
||
for (let i = 0; i < line.length; i++) {
|
||
const ch = line[i];
|
||
if (ch === '"') {
|
||
if (inQuotes && line[i + 1] === '"') { current += '"'; i++; }
|
||
else inQuotes = !inQuotes;
|
||
} else if (ch === ',' && !inQuotes) {
|
||
result.push(current); current = '';
|
||
} else {
|
||
current += ch;
|
||
}
|
||
}
|
||
result.push(current);
|
||
return result;
|
||
}
|
||
|
||
function parseCSV(text) {
|
||
const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());
|
||
if (lines.length < 2) return [];
|
||
const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/\s+/g, '_'));
|
||
return lines.slice(1)
|
||
.map(line => {
|
||
const vals = parseCSVLine(line);
|
||
const row = {};
|
||
headers.forEach((h, i) => { row[h] = (vals[i] ?? '').trim(); });
|
||
return row;
|
||
})
|
||
.filter(row => Object.values(row).some(v => v));
|
||
}
|
||
|
||
async function importCSVRows(rows) {
|
||
// Per-import caches so the same name doesn't trigger multiple API calls
|
||
const srcCache = new Map(); // name.lower → id
|
||
const tunCache = new Map(); // name.lower → id (tunings)
|
||
const musCache = new Map(); // name.lower → id (musicians)
|
||
const instCache = new Map(); // name.lower → id (instruments)
|
||
const termCache = new Map(); // name.lower → id (terms)
|
||
const tuneCache = new Map(); // name.lower → id (tunes created this run)
|
||
const results = { created: 0, skipped: 0, errors: [] };
|
||
|
||
async function resolveOrCreate(endpoint, list, cache, name) {
|
||
if (!name) return null;
|
||
const lower = name.toLowerCase();
|
||
if (cache.has(lower)) return cache.get(lower);
|
||
const existing = list.find(x => x.name.toLowerCase() === lower);
|
||
if (existing) { cache.set(lower, existing.id); return existing.id; }
|
||
const item = await apiFetch(endpoint, { method: 'POST', body: JSON.stringify({ name }) });
|
||
list.push(item);
|
||
cache.set(lower, item.id);
|
||
return item.id;
|
||
}
|
||
|
||
for (const row of rows) {
|
||
const tuneName = row.name?.trim();
|
||
if (!tuneName) { results.skipped++; continue; }
|
||
try {
|
||
const sourceId = await resolveOrCreate('/sources/', allSources, srcCache, row.source?.trim() || null);
|
||
const tuningId = await resolveOrCreate('/tunings/', allTunings, tunCache, row.tuning?.trim() || null);
|
||
const learnedFromId = await resolveOrCreate('/musicians/', allMusicians, musCache, row.learned_from?.trim() || null);
|
||
const instId = await resolveOrCreate('/instruments', allInstruments, instCache, row.instrument?.trim() || null);
|
||
const termId = await resolveOrCreate('/terms/', allTerms, termCache, row.term?.trim() || null);
|
||
|
||
// Find existing tune or create new
|
||
const lower = tuneName.toLowerCase();
|
||
let tuneId = tuneCache.get(lower)
|
||
?? allTunes.find(t => (t.name ?? '').toLowerCase() === lower)?.id;
|
||
|
||
if (!tuneId) {
|
||
const modal = row.modal?.toLowerCase() === 'true' || row.modal === '1';
|
||
const created = await apiFetch('/tunes/', {
|
||
method: 'POST',
|
||
body: JSON.stringify({ name: tuneName, key: row.key?.trim() || null, modal, source_id: sourceId }),
|
||
});
|
||
tuneId = created.id;
|
||
tuneCache.set(lower, tuneId);
|
||
}
|
||
|
||
// Add instrument entry (ignore 409 = already exists for that instrument)
|
||
if (instId) {
|
||
const status = ['callable', 'review', 'to_learn'].includes(row.status?.trim()) ? row.status.trim() : null;
|
||
const difficulty = ['easy', 'hard'].includes(row.difficulty?.trim()?.toLowerCase()) ? row.difficulty.trim().toLowerCase() : null;
|
||
try {
|
||
await apiFetch(`/tunes/${tuneId}/instruments`, {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
instrument_id: instId,
|
||
tuning_id: tuningId,
|
||
term_id: termId,
|
||
learned_from_id: learnedFromId,
|
||
date_learned: row.date_learned?.trim() || null,
|
||
status,
|
||
difficulty,
|
||
}),
|
||
});
|
||
} catch (e) {
|
||
if (!e.message.startsWith('409')) throw e;
|
||
}
|
||
}
|
||
|
||
results.created++;
|
||
} catch (e) {
|
||
results.errors.push({ name: tuneName, error: e.message });
|
||
}
|
||
}
|
||
return results;
|
||
}
|
||
|
||
// ── 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_term, termDatalist);
|
||
attachTabAutocomplete(tuneForm.elements.inst_learned_from, musicianDatalist);
|
||
|
||
// ── Boot ─────────────────────────────────────────────────────
|
||
loadAll();
|