Initial Commit
This commit is contained in:
commit
eb130ffcfd
6 changed files with 1265 additions and 0 deletions
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
config.js
|
||||||
31
README.md
Normal file
31
README.md
Normal file
|
|
@ -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.
|
||||||
725
app.js
Normal file
725
app.js
Normal file
|
|
@ -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 = '<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;
|
||||||
|
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 = `
|
||||||
|
<td>${esc(tune.name ?? '')}</td>
|
||||||
|
<td>${esc(tune.key ?? '')}</td>
|
||||||
|
<td>${esc(tune.source?.name ?? '')}</td>
|
||||||
|
<td>${tune.modal ? '<span class="pill pill-yes">Yes</span>' : '<span class="pill" style="background:#222">—</span>'}</td>
|
||||||
|
<td>${esc(entry?.instrument_name ?? '')}</td>
|
||||||
|
<td>${esc(entry?.tuning ?? '')}</td>
|
||||||
|
<td>${pillBool(entry?.callable)}</td>
|
||||||
|
<td>${pillBool(entry?.review, true)}</td>
|
||||||
|
<td>${pillBool(entry?.to_learn, false, true)}</td>
|
||||||
|
<td>${hasNotes
|
||||||
|
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</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>`;
|
||||||
|
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 '<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>';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── 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 = `<input type="text" placeholder="URL" value="${esc(link)}" class="ref-link" />
|
||||||
|
<input type="text" placeholder="Site" value="${esc(site)}" class="ref-site" style="max-width:100px" />
|
||||||
|
<button type="button" class="remove-btn">✕</button>`;
|
||||||
|
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 = `<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', 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, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Boot ─────────────────────────────────────────────────────
|
||||||
|
loadAll();
|
||||||
15
config.example.js
Normal file
15
config.example.js
Normal file
|
|
@ -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",
|
||||||
|
};
|
||||||
143
index.html
Normal file
143
index.html
Normal file
|
|
@ -0,0 +1,143 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Repertory</title>
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<header>
|
||||||
|
<h1>🎵 Repertory</h1>
|
||||||
|
<div id="header-actions">
|
||||||
|
<input type="search" id="filter-search" placeholder="Search…" />
|
||||||
|
<button id="btn-clear-filters" hidden>✕ Clear filters</button>
|
||||||
|
<span id="status"></span>
|
||||||
|
<button id="btn-add-tune" hidden>+ Add tune</button>
|
||||||
|
<button id="btn-unlock">🔒 Unlock</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- ── Tune table ───────────────────────────────────────────── -->
|
||||||
|
<main>
|
||||||
|
<table id="tune-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th data-col="name">Name <span class="sort-indicator"></span></th>
|
||||||
|
<th data-filter="key">Key</th>
|
||||||
|
<th data-filter="source">Source</th>
|
||||||
|
<th data-filter="modal">Modal</th>
|
||||||
|
<th data-filter="instrument">Instrument</th>
|
||||||
|
<th data-filter="tuning">Tuning</th>
|
||||||
|
<th data-filter="callable">Callable</th>
|
||||||
|
<th data-filter="review">Review</th>
|
||||||
|
<th data-filter="to_learn">To learn</th>
|
||||||
|
<th>Notes</th>
|
||||||
|
<th class="edit-col" hidden></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="tune-tbody"></tbody>
|
||||||
|
</table>
|
||||||
|
<p id="empty-msg" hidden>No tunes match the current filters.</p>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- ── Filter popover (shared, moved in DOM) ────────────────── -->
|
||||||
|
<div id="filter-popover" hidden>
|
||||||
|
<div id="filter-popover-inner"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Add / Edit tune modal ────────────────────────────────── -->
|
||||||
|
<dialog id="tune-modal">
|
||||||
|
<form id="tune-form" method="dialog">
|
||||||
|
<h2 id="modal-title">Add tune</h2>
|
||||||
|
|
||||||
|
<fieldset>
|
||||||
|
<legend>Tune</legend>
|
||||||
|
<label>Name <input name="name" type="text" /></label>
|
||||||
|
<label>Key <input name="key" type="text" placeholder="e.g. D" /></label>
|
||||||
|
<label>Modal <input name="modal" type="checkbox" /></label>
|
||||||
|
<label>Source
|
||||||
|
<span class="autocomplete-row">
|
||||||
|
<input name="source_name" id="source-input" type="text"
|
||||||
|
list="source-list" placeholder="— none —" autocomplete="off" />
|
||||||
|
<datalist id="source-list"></datalist>
|
||||||
|
<button type="button" class="quick-add-btn" id="btn-quick-add-source" title="Add new source">+</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<fieldset id="fs-instrument">
|
||||||
|
<legend>Instrument entry</legend>
|
||||||
|
<label>Instrument
|
||||||
|
<select name="instrument_id" id="instrument-select">
|
||||||
|
<option value="">— none —</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
<label>Tuning
|
||||||
|
<span class="autocomplete-row">
|
||||||
|
<input name="inst_tuning" type="text"
|
||||||
|
list="tuning-list" placeholder="— none —" autocomplete="off" />
|
||||||
|
<datalist id="tuning-list"></datalist>
|
||||||
|
<button type="button" class="quick-add-btn" id="btn-quick-add-tuning" title="Add new tuning">+</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label>Learned from
|
||||||
|
<span class="autocomplete-row">
|
||||||
|
<input name="inst_learned_from" type="text"
|
||||||
|
list="musician-list" placeholder="— none —" autocomplete="off" />
|
||||||
|
<datalist id="musician-list"></datalist>
|
||||||
|
<button type="button" class="quick-add-btn" id="btn-quick-add-musician" title="Add new musician">+</button>
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
<label>Date learned <input name="inst_date_learned" type="date" /></label>
|
||||||
|
<label>Callable <input name="inst_callable" type="checkbox" /></label>
|
||||||
|
<label>Review <input name="inst_review" type="checkbox" /></label>
|
||||||
|
<label>To learn <input name="inst_to_learn" type="checkbox" /></label>
|
||||||
|
<label>Difficulty
|
||||||
|
<select name="inst_difficulty">
|
||||||
|
<option value="">—</option>
|
||||||
|
<option value="easy">Easy</option>
|
||||||
|
<option value="hard">Hard</option>
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</fieldset>
|
||||||
|
|
||||||
|
<div id="refs-section">
|
||||||
|
<h3>References</h3>
|
||||||
|
<div id="refs-list"></div>
|
||||||
|
<button type="button" id="btn-add-ref">+ reference</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button type="button" id="btn-delete-tune" class="btn-danger" hidden>Delete tune</button>
|
||||||
|
<button type="button" id="btn-edit-notes" hidden>📝 Notes</button>
|
||||||
|
<button type="button" id="btn-save-tune">Save</button>
|
||||||
|
<button type="button" id="btn-cancel">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- ── Notes modal ──────────────────────────────────────────── -->
|
||||||
|
<dialog id="notes-modal">
|
||||||
|
<h2 id="notes-modal-title">Notes</h2>
|
||||||
|
<div id="tune-notes-section">
|
||||||
|
<h3>Tune notes</h3>
|
||||||
|
<div id="tune-notes-list"></div>
|
||||||
|
<button type="button" id="btn-add-tune-note" hidden>+ note</button>
|
||||||
|
</div>
|
||||||
|
<div id="inst-notes-section">
|
||||||
|
<h3 id="inst-notes-heading">Instrument notes</h3>
|
||||||
|
<div id="inst-notes-list"></div>
|
||||||
|
<button type="button" id="btn-add-inst-note" hidden>+ note</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-actions">
|
||||||
|
<button id="btn-notes-close">Close</button>
|
||||||
|
</div>
|
||||||
|
</dialog>
|
||||||
|
|
||||||
|
<!-- config.js is gitignored — copy config.example.js to config.js for local dev -->
|
||||||
|
<script src="config.js" onerror="void 0"></script>
|
||||||
|
<script src="app.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
350
style.css
Normal file
350
style.css
Normal file
|
|
@ -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; }
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue