From eb130ffcfd2b91f803f8ef615075d45288baca45 Mon Sep 17 00:00:00 2001 From: Ian Keane Date: Tue, 9 Jun 2026 15:04:27 -0400 Subject: [PATCH] Initial Commit --- .gitignore | 1 + README.md | 31 ++ app.js | 725 ++++++++++++++++++++++++++++++++++++++++++++++ config.example.js | 15 + index.html | 143 +++++++++ style.css | 350 ++++++++++++++++++++++ 6 files changed, 1265 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 app.js create mode 100644 config.example.js create mode 100644 index.html create mode 100644 style.css diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1bf4259 --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +config.js diff --git a/README.md b/README.md new file mode 100644 index 0000000..02a9f04 --- /dev/null +++ b/README.md @@ -0,0 +1,31 @@ +# Repertory — frontend + +Static single-page app for browsing and editing your tune repertory. + +## Local development + +```bash +cp config.example.js config.js +# edit config.js — set apiUrl and apiKey to match your local .env +python -m http.server 8080 +# open http://localhost:8080 +``` + +`config.js` is gitignored and never deployed to S3. It pre-fills the +connection bar so you don't have to type the API URL and key on every page +load. See `config.example.js` for the full explanation of how this works. + +## Deployment + +This is a fully static site (HTML + CSS + JS, no build step). +Upload these files to your S3 bucket — `config.js` is intentionally excluded: + +```bash +aws s3 sync . s3://your-bucket-name/ \ + --exclude "*" \ + --include "index.html" \ + --include "style.css" \ + --include "app.js" +``` + +In production, enter your API URL and key in the connection bar at the top of the page. These are saved in `sessionStorage` so you only need to enter them once per browser session. diff --git a/app.js b/app.js new file mode 100644 index 0000000..1548cde --- /dev/null +++ b/app.js @@ -0,0 +1,725 @@ +/* ───────────────────────────────────────────────────────────── + 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(); diff --git a/config.example.js b/config.example.js new file mode 100644 index 0000000..75a779f --- /dev/null +++ b/config.example.js @@ -0,0 +1,15 @@ +// Local development config. +// +// Copy this file to config.js and fill in your values: +// cp config.example.js config.js +// +// config.js is gitignored and never deployed to S3 — it is purely a local +// dev convenience so you don't have to type the API URL and key into the UI +// on every page load. +// +// In production the connection bar in the UI is used instead. The values +// entered there are saved in sessionStorage for the duration of the session. +window.REPERTORY_CONFIG = { + apiUrl: "http://localhost:5000", + apiKey: "dev-secret-key-change-me", +}; diff --git a/index.html b/index.html new file mode 100644 index 0000000..689325c --- /dev/null +++ b/index.html @@ -0,0 +1,143 @@ + + + + + + Repertory + + + + +
+

🎵 Repertory

+
+ + + + + +
+
+ + +
+ + + + + + + + + + + + + + + + + +
Name KeySourceModalInstrumentTuningCallableReviewTo learnNotes
+ +
+ + + + + + +
+ + +
+ Tune + + + + +
+ +
+ Instrument entry + + + + + + + + +
+ +
+

References

+
+ +
+ + +
+
+ + + +

Notes

+
+

Tune notes

+
+ +
+
+

Instrument notes

+
+ +
+ +
+ + + + + + diff --git a/style.css b/style.css new file mode 100644 index 0000000..9c3bdf0 --- /dev/null +++ b/style.css @@ -0,0 +1,350 @@ +/* ── Reset & base ─────────────────────────────────────────── */ +*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } + +:root { + --bg: #1a1a2e; + --surface: #16213e; + --border: #0f3460; + --accent: #e94560; + --accent2: #533483; + --text: #eaeaea; + --muted: #8892a4; + --radius: 6px; + --font: 'Segoe UI', system-ui, sans-serif; +} + +body { + font-family: var(--font); + background: var(--bg); + color: var(--text); + min-height: 100vh; + display: flex; + flex-direction: column; +} + +/* ── Header ───────────────────────────────────────────────── */ +header { + background: var(--surface); + border-bottom: 2px solid var(--border); + padding: .75rem 1.25rem; + display: flex; + align-items: center; + justify-content: space-between; + gap: .75rem; +} + +header h1 { font-size: 1.4rem; white-space: nowrap; } + +#header-actions { + display: flex; + gap: .6rem; + align-items: center; + flex: 1; + justify-content: flex-end; +} + +#filter-search { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius); + padding: .3rem .6rem; + font-size: .85rem; + width: 180px; +} + +#btn-unlock { background: transparent; border: 1px solid var(--border); color: var(--muted); } +#btn-add-tune { background: var(--accent); } +#btn-clear-filters { background: transparent; border: 1px solid var(--border); color: var(--muted); font-size: .8rem; } +#status { font-size: .8rem; color: var(--muted); } + +/* ── Column header filters ────────────────────────────────── */ +th[data-filter] { + cursor: pointer; + user-select: none; + white-space: nowrap; +} + +th[data-filter]::after { + content: ' ▾'; + font-size: .7em; + opacity: .5; +} + +th[data-filter].filter-active::after { + content: ' ▾'; + opacity: 1; +} + +th[data-filter].filter-active { + color: var(--text); +} + +th[data-filter].filter-active::before { + content: '● '; + color: var(--accent); + font-size: .6em; + vertical-align: middle; +} + +/* ── Filter popover ───────────────────────────────────────── */ +#filter-popover { + position: fixed; + z-index: 100; + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .6rem .75rem; + min-width: 160px; + box-shadow: 0 4px 16px rgba(0,0,0,.4); +} + +#filter-popover-inner select, +#filter-popover-inner input[type="text"] { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius); + padding: .3rem .5rem; + font-size: .85rem; + width: 100%; +} + +.popover-checkbox-row { + display: flex; + align-items: center; + gap: .5rem; + padding: .25rem 0; + font-size: .875rem; + cursor: pointer; +} + +.popover-checkbox-row input[type="checkbox"] { + width: 1rem; + height: 1rem; + accent-color: var(--accent); + flex-shrink: 0; +} + +.popover-clear { + margin-top: .5rem; + font-size: .75rem; + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + padding: 0; + text-decoration: underline; +} +.popover-clear:hover { color: var(--text); } + +/* ── Buttons ──────────────────────────────────────────────── */ +button { + cursor: pointer; + border: none; + border-radius: var(--radius); + padding: .35rem .75rem; + font-size: .85rem; + background: var(--accent2); + color: var(--text); + transition: opacity .15s; +} +button:hover { opacity: .85; } + +/* ── Table ────────────────────────────────────────────────── */ +main { + flex: 1; + overflow-x: auto; + padding: 1rem 1.25rem; +} + +#tune-table { + width: 100%; + border-collapse: collapse; + font-size: .875rem; +} + +#tune-table th, +#tune-table td { + padding: .5rem .75rem; + text-align: left; + border-bottom: 1px solid var(--border); + white-space: nowrap; +} + +#tune-table thead th { + background: var(--surface); + color: var(--muted); + font-weight: 600; + user-select: none; +} + +#tune-table thead th[data-col] { cursor: pointer; } +#tune-table thead th[data-col]:hover { color: var(--text); } + +.sort-indicator::after { content: ''; margin-left: .3em; } +th.sort-asc .sort-indicator::after { content: '▲'; } +th.sort-desc .sort-indicator::after { content: '▼'; } + +#tune-table tbody tr:nth-child(even) { background: rgba(255,255,255,.025); } +#tune-table tbody tr:hover { background: rgba(255,255,255,.055); } + +.pill { + display: inline-block; + padding: .15rem .45rem; + border-radius: 99px; + font-size: .75rem; + font-weight: 600; +} +.pill-yes { background: #1e4d2b; color: #6fcf97; } +.pill-no { background: #4d1e1e; color: #eb5757; } +.pill-warn { background: #4d3a1e; color: #f2c94c; } + +.action-btn { + background: transparent; + border: 1px solid var(--border); + padding: .2rem .5rem; + font-size: .75rem; + margin-right: .25rem; +} + +#empty-msg { color: var(--muted); text-align: center; padding: 2rem; } + +/* ── Modal ────────────────────────────────────────────────── */ +dialog { + background: var(--surface); + color: var(--text); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.5rem; + max-width: 560px; + width: 95vw; + max-height: 90vh; + overflow-y: auto; +} + +dialog::backdrop { background: rgba(0,0,0,.65); } + +dialog h2 { margin-bottom: 1rem; } +dialog h3 { font-size: .95rem; margin: .75rem 0 .4rem; color: var(--muted); } + +fieldset { + border: 1px solid var(--border); + border-radius: var(--radius); + padding: .75rem; + margin-bottom: .75rem; +} + +fieldset legend { + padding: 0 .35rem; + font-size: .85rem; + color: var(--muted); + display: flex; + align-items: center; + gap: .4rem; +} + +fieldset label { + display: grid; + grid-template-columns: 120px 1fr; + align-items: center; + gap: .4rem; + margin-bottom: .4rem; + font-size: .85rem; +} + +fieldset input[type="text"], +fieldset input[type="date"], +fieldset select { + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius); + padding: .3rem .5rem; + font-size: .85rem; + width: 100%; +} + +.autocomplete-row { + display: flex; + gap: .4rem; + align-items: center; +} + +.autocomplete-row input { flex: 1; } +.quick-add-btn { flex-shrink: 0; padding: .3rem .6rem; font-size: .85rem; background: var(--accent2); } + +/* collapse instrument fieldset when checkbox unchecked */ +fieldset .fs-body { display: none; } +fieldset.active .fs-body { display: contents; } + +.note-row, .ref-row { + display: flex; + gap: .4rem; + margin-bottom: .4rem; + align-items: flex-start; +} + +.note-row input, .ref-row input { + flex: 1; + background: var(--bg); + border: 1px solid var(--border); + color: var(--text); + border-radius: var(--radius); + padding: .3rem .5rem; + font-size: .85rem; +} + +.remove-btn { + background: transparent; + border: 1px solid var(--border); + color: var(--muted); + padding: .2rem .45rem; + font-size: .75rem; +} + +.btn-danger { background: var(--accent); } + +.note-row { + display: flex; + align-items: center; + gap: .5rem; + padding: .3rem 0; + border-bottom: 1px solid var(--border); +} +.note-row:last-child { border-bottom: none; } +.note-text { flex: 1; font-size: .875rem; } +.note-edit-controls { display: flex; gap: .25rem; } + +.btn-edit-row { background: transparent; border: none; font-size: 1rem; padding: .2rem .3rem; } + +#btn-delete-tune { background: #4d1e1e; color: #eb5757; border: 1px solid #eb5757; margin-right: auto; } + +.modal-actions { + display: flex; + gap: .6rem; + justify-content: flex-end; + margin-top: 1rem; +} + +#btn-save-tune { background: var(--accent); } +#btn-cancel { background: transparent; border: 1px solid var(--border); } + +/* ── Detail modal ─────────────────────────────────────────── */ +#detail-content h2 { margin-bottom: .75rem; } +#detail-content table { width: 100%; border-collapse: collapse; font-size: .85rem; } +#detail-content td { padding: .3rem .5rem; border-bottom: 1px solid var(--border); } +#detail-content td:first-child { color: var(--muted); width: 120px; } +#detail-content h3 { margin: 1rem 0 .4rem; font-size: .9rem; color: var(--muted); } + +#btn-detail-close { + margin-top: 1rem; + background: transparent; + border: 1px solid var(--border); +} + +/* ── Responsive ───────────────────────────────────────────── */ +@media (max-width: 600px) { + #tune-table th:nth-child(n+5), + #tune-table td:nth-child(n+5) { display: none; } +}