Compare commits

..

No commits in common. "9dc16c6c637c596e0c3083d6bcf65597d916ce2b" and "3810d1c05cdd26c8fcb646302854754deaeefdb6" have entirely different histories.

3 changed files with 138 additions and 552 deletions

486
app.js
View file

@ -10,19 +10,16 @@ let allMusicians = [];
let allInstruments = []; let allInstruments = [];
let allTunings = []; let allTunings = [];
let allReferenceSites = []; let allReferenceSites = [];
let allTerms = [];
let sortCol = 'name'; let sortCol = 'name';
let sortDir = 'asc'; let sortDir = 'asc';
// editing state // editing state
let editingTuneId = null; // null = add mode let editingTuneId = null; // null = add mode
let editingEntryId = null; // null = no existing instrument entry let editingEntryId = null; // null = no existing instrument entry
let editingOriginalRefIds = []; // ref IDs present when edit modal opened
// notes modal state // notes modal state
let notesTuneId = null; let notesTuneId = null;
let notesEntryId = null; let notesEntryId = null;
let noteEditorState = null; // { type: 'tune'|'inst', noteId: null|number }
let unlocked = false; let unlocked = false;
@ -44,7 +41,6 @@ const sourceDatalist = $('source-list');
const musicianDatalist = $('musician-list'); const musicianDatalist = $('musician-list');
const instrumentSelect = $('instrument-select'); const instrumentSelect = $('instrument-select');
const tuningDatalist = $('tuning-list'); const tuningDatalist = $('tuning-list');
const termDatalist = $('term-list');
const btnDeleteTune = $('btn-delete-tune'); const btnDeleteTune = $('btn-delete-tune');
const btnEditNotes = $('btn-edit-notes'); const btnEditNotes = $('btn-edit-notes');
const btnBulkEdit = $('btn-bulk-edit'); const btnBulkEdit = $('btn-bulk-edit');
@ -118,20 +114,18 @@ function setStatus(msg, isError = false) {
async function loadAll() { async function loadAll() {
setStatus('Loading…'); setStatus('Loading…');
try { try {
[allTunes, allSources, allMusicians, allInstruments, allTunings, allReferenceSites, allTerms] = await Promise.all([ [allTunes, allSources, allMusicians, allInstruments, allTunings, allReferenceSites] = await Promise.all([
apiFetch('/tunes/'), apiFetch('/tunes/'),
apiFetch('/sources/'), apiFetch('/sources/'),
apiFetch('/musicians/'), apiFetch('/musicians/'),
apiFetch('/instruments'), apiFetch('/instruments'),
apiFetch('/tunings/'), apiFetch('/tunings/'),
apiFetch('/reference_sites/'), apiFetch('/reference_sites/'),
apiFetch('/terms/'),
]); ]);
populateFilterOptions(); populateFilterOptions();
populateDatalist(sourceDatalist, allSources); populateDatalist(sourceDatalist, allSources);
populateDatalist(musicianDatalist, allMusicians); populateDatalist(musicianDatalist, allMusicians);
populateDatalist(tuningDatalist, allTunings); populateDatalist(tuningDatalist, allTunings);
populateDatalist(termDatalist, allTerms);
populateDatalist(refSiteDatalist, allReferenceSites); populateDatalist(refSiteDatalist, allReferenceSites);
populateInstrumentSelect(); populateInstrumentSelect();
renderTable(); renderTable();
@ -208,12 +202,6 @@ $('btn-quick-add-tuning').addEventListener('click', async () => {
}); });
}); });
$('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 () => { $('btn-quick-add-instrument').addEventListener('click', async () => {
const item = await quickAdd('/instruments', 'New instrument name:', allInstruments, null); const item = await quickAdd('/instruments', 'New instrument name:', allInstruments, null);
if (item) { if (item) {
@ -241,11 +229,6 @@ function resolveTuningId(name) {
return allTunings.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? 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 ──────────────────────────── // ── Rows: one per instrument entry ────────────────────────────
// For tunes with no instrument entries, emit one row with empty instrument cols // For tunes with no instrument entries, emit one row with empty instrument cols
function buildRows(tunes) { function buildRows(tunes) {
@ -433,7 +416,7 @@ function renderTable() {
emptyMsg.hidden = rows.length > 0; emptyMsg.hidden = rows.length > 0;
rows.forEach(({ tune, entry }) => { rows.forEach(({ tune, entry }) => {
const hasNotes = tune.has_notes ?? false; const hasNotes = (tune.notes?.length > 0) || (entry?.notes?.length > 0);
// ── Mobile 2-line card data ────────────────────────────────── // ── Mobile 2-line card data ──────────────────────────────────
const nameRaw = tune.name ?? ''; const nameRaw = tune.name ?? '';
@ -448,7 +431,7 @@ function renderTable() {
const mobPills = entry ? pillStatus(entry.status, 'mob-pill') : ''; const mobPills = entry ? pillStatus(entry.status, 'mob-pill') : '';
const mobActions = [ const mobActions = [
hasNotes ? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>` : '', 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>` : '', tune.references?.length > 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>`, `<button class="action-btn btn-edit-row" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}" ${unlocked ? '' : 'hidden'}>✏️</button>`,
].join(''); ].join('');
@ -457,13 +440,13 @@ function renderTable() {
<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-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><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>${esc(keyStr)}</td>
<td><span class="cell-trunc cell-inst-content" title="${esc(entry?.instrument_name ?? '')}">${esc(entry?.instrument_name ?? '')}</span></td> <td>${esc(entry?.instrument_name ?? '')}</td>
<td><span class="cell-trunc cell-tuning-content" title="${esc(entry?.tuning ?? '')}">${esc(entry?.tuning ?? '')}</span></td> <td>${esc(entry?.tuning ?? '')}</td>
<td>${pillStatus(entry?.status)}</td> <td>${pillStatus(entry?.status)}</td>
<td>${hasNotes <td>${hasNotes
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>` ? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
: ''}</td> : ''}</td>
<td>${(tune.reference_count ?? 0) > 0 <td>${tune.references?.length > 0
? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}" title="References">🔗</button>` ? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}" title="References">🔗</button>`
: ''}</td> : ''}</td>
<td><button class="action-btn btn-edit-row" <td><button class="action-btn btn-edit-row"
@ -518,8 +501,6 @@ btnAddTune.addEventListener('click', openAdd);
function openAdd() { function openAdd() {
editingTuneId = null; editingTuneId = null;
editingEntryId = null; editingEntryId = null;
editingOriginalRefIds = [];
setTuneError('');
$('modal-title').textContent = 'Add tune'; $('modal-title').textContent = 'Add tune';
tuneForm.reset(); tuneForm.reset();
refsList.innerHTML = ''; refsList.innerHTML = '';
@ -531,49 +512,38 @@ function openAdd() {
tuneModal.showModal(); tuneModal.showModal();
} }
async function openEdit(tuneId, entryId) { function openEdit(tuneId, entryId) {
editingTuneId = tuneId; editingTuneId = tuneId;
editingEntryId = entryId; editingEntryId = entryId;
// Open modal immediately — name from slim data gives instant feedback const tune = allTunes.find(t => t.id === tuneId);
const slimTune = allTunes.find(t => t.id === tuneId); const entry = entryId ? tune?.instruments.find(e => e.id === entryId) : null;
$('modal-title').textContent = `Edit: ${slimTune?.name ?? ''}`;
$('modal-title').textContent = `Edit: ${tune?.name ?? ''}`;
tuneForm.reset(); tuneForm.reset();
refsList.innerHTML = '';
btnDeleteTune.hidden = false;
btnEditNotes.hidden = false;
tuneModal.showModal();
// Fetch full tune for references and confirmed field values tuneForm.elements.name.value = tune?.name ?? '';
let fullTune; tuneForm.elements.key.value = tune?.key ?? '';
try { tuneForm.elements.modal.checked = !!tune?.modal;
fullTune = await apiFetch(`/tunes/${tuneId}`); sourceInput.value = tune?.source?.name ?? '';
} 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 ?? '';
// Instrument entry
populateInstrumentSelect(entry?.instrument_id ?? null); populateInstrumentSelect(entry?.instrument_id ?? null);
if (entry) { if (entry) {
tuneForm.elements.inst_tuning.value = entry.tuning ?? ''; 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_learned_from.value = entry.learned_from ?? '';
tuneForm.elements.inst_date_learned.value = entry.date_learned ?? ''; tuneForm.elements.inst_date_learned.value = entry.date_learned ?? '';
tuneForm.elements.inst_status.value = entry.status ?? ''; tuneForm.elements.inst_status.value = entry.status ?? '';
tuneForm.elements.inst_difficulty.value = entry.difficulty ?? ''; tuneForm.elements.inst_difficulty.value = entry.difficulty ?? '';
} }
editingOriginalRefIds = (fullTune.references ?? []).map(r => r.id); // References
(fullTune.references ?? []).forEach(r => addRefRow(r.link, r.site?.name ?? '', r.id, r.site_id, r.musicians ?? [])); refsList.innerHTML = '';
(tune?.references ?? []).forEach(r => addRefRow(r.link, r.site?.name ?? '', r.id, r.site_id, r.musicians ?? []));
btnDeleteTune.hidden = false;
btnEditNotes.hidden = false;
tuneModal.showModal();
} }
// ── Musician chip picker (shared by ref rows + bulk form) ────── // ── Musician chip picker (shared by ref rows + bulk form) ──────
@ -676,43 +646,11 @@ function addRefRow(link = '', site = '', refId = null, siteId = null, initialMus
// ── Save ────────────────────────────────────────────────────── // ── Save ──────────────────────────────────────────────────────
$('btn-save-tune').addEventListener('click', saveTune); $('btn-save-tune').addEventListener('click', saveTune);
$('btn-cancel').addEventListener('click', () => { setTuneError(''); tuneModal.close(); }); $('btn-cancel').addEventListener('click', () => tuneModal.close());
function setTuneError(msg) {
const el = $('tune-error');
el.textContent = msg;
el.hidden = !msg;
}
async function saveTune() { async function saveTune() {
const f = tuneForm.elements; 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 = { const tuneBody = {
name: f.name.value || null, name: f.name.value || null,
key: f.key.value || null, key: f.key.value || null,
@ -724,7 +662,6 @@ async function saveTune() {
const instBody = instId ? { const instBody = instId ? {
instrument_id: instId, instrument_id: instId,
tuning_id: resolveTuningId(f.inst_tuning.value), tuning_id: resolveTuningId(f.inst_tuning.value),
term_id: resolveTermId(f.inst_term.value),
learned_from_id: resolveMusicianId(f.inst_learned_from.value), learned_from_id: resolveMusicianId(f.inst_learned_from.value),
date_learned: f.inst_date_learned.value || null, date_learned: f.inst_date_learned.value || null,
status: f.inst_status.value || null, status: f.inst_status.value || null,
@ -770,35 +707,6 @@ async function saveTune() {
}); });
} }
} }
// 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(); tuneModal.close();
@ -831,30 +739,22 @@ async function openNotesModal(tuneId, entryId) {
notesTuneId = tuneId; notesTuneId = tuneId;
notesEntryId = entryId; notesEntryId = entryId;
// Open immediately with name from slim data const tune = allTunes.find(t => t.id === tuneId);
const slimTune = allTunes.find(t => t.id === tuneId); const entry = entryId ? tune?.instruments.find(e => e.id === entryId) : null;
$('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 $('notes-modal-title').textContent = `Notes — ${tune?.name ?? ''}`;
try { $('inst-notes-heading').textContent = entry
const fullTune = await apiFetch(`/tunes/${tuneId}`); ? `${entry.instrument_name} notes`
const entry = entryId ? fullTune.instruments.find(e => e.id === entryId) : null; : 'Instrument notes';
$('inst-notes-heading').textContent = entry ? `${entry.instrument_name} notes` : 'Instrument notes';
$('inst-notes-section').hidden = !entry; $('inst-notes-section').hidden = !entry;
renderTuneNotes(tune?.notes ?? []);
renderInstNotes(entry?.notes ?? []);
$('btn-add-tune-note').hidden = !unlocked;
$('btn-add-inst-note').hidden = !unlocked || !entry; $('btn-add-inst-note').hidden = !unlocked || !entry;
renderTuneNotes(fullTune.notes ?? []); notesModal.showModal();
renderInstNotes(entry?.notes ?? []);
} catch (e) {
setStatus(e.message, true);
}
} }
function renderTuneNotes(notes) { function renderTuneNotes(notes) {
@ -891,62 +791,35 @@ function renderInstNotes(notes) {
}); });
} }
$('btn-add-tune-note').addEventListener('click', () => showNoteEditor('tune', null)); $('btn-add-tune-note').addEventListener('click', async () => {
$('btn-add-inst-note').addEventListener('click', () => showNoteEditor('inst', null)); const text = prompt('Note:');
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; if (!text) return;
const { type, noteId } = noteEditorState;
$('btn-note-save').disabled = true;
try { try {
if (type === 'tune') { await apiFetch(`/tunes/${notesTuneId}/notes`, { method: 'POST', body: JSON.stringify({ note: text }) });
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(); await reloadNotesModal();
} catch (e) { } catch (e) { setStatus(e.message, true); }
setStatus(e.message, true);
} finally {
$('btn-note-save').disabled = false;
}
}); });
$('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) { async function deleteTuneNote(noteId) {
if (!confirm('Delete this note?')) return; if (!confirm('Delete this note?')) return;
try { try {
@ -955,6 +828,16 @@ async function deleteTuneNote(noteId) {
} catch (e) { setStatus(e.message, true); } } 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) { async function deleteInstNote(noteId) {
if (!confirm('Delete this note?')) return; if (!confirm('Delete this note?')) return;
try { try {
@ -965,20 +848,11 @@ async function deleteInstNote(noteId) {
} }
async function reloadNotesModal() { async function reloadNotesModal() {
try { await loadAll();
const fullTune = await apiFetch(`/tunes/${notesTuneId}`); const tune = allTunes.find(t => t.id === notesTuneId);
const entry = notesEntryId ? fullTune.instruments.find(e => e.id === notesEntryId) : null; const entry = notesEntryId ? tune?.instruments.find(e => e.id === notesEntryId) : null;
renderTuneNotes(fullTune.notes ?? []); renderTuneNotes(tune?.notes ?? []);
renderInstNotes(entry?.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()); $('btn-notes-close').addEventListener('click', () => notesModal.close());
@ -986,15 +860,12 @@ $('btn-notes-close').addEventListener('click', () => notesModal.close());
// ── References display modal ──────────────────────────────────── // ── References display modal ────────────────────────────────────
$('btn-refs-modal-close').addEventListener('click', () => refsModal.close()); $('btn-refs-modal-close').addEventListener('click', () => refsModal.close());
async function openRefsModal(tuneId) { function openRefsModal(tuneId) {
const tune = allTunes.find(t => t.id === tuneId); const tune = allTunes.find(t => t.id === tuneId);
$('refs-modal-title').textContent = `References — ${tune?.name ?? ''}`; $('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 = ''; refsModalList.innerHTML = '';
const refs = tune?.references ?? [];
if (refs.length === 0) { if (refs.length === 0) {
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">No references.</p>'; refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">No references.</p>';
} else { } else {
@ -1004,20 +875,14 @@ async function openRefsModal(tuneId) {
const musicians = r.musicians?.map(m => m.name).join(', ') ?? ''; const musicians = r.musicians?.map(m => m.name).join(', ') ?? '';
const siteName = r.site?.name ?? ''; const siteName = r.site?.name ?? '';
const meta = [siteName, musicians].filter(Boolean).join(' · '); 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 = ` item.innerHTML = `
<a href="${esc(r.link ?? '')}" target="_blank" rel="noopener" class="ref-link-anchor">${esc(displayText)}</a>`; <a href="${esc(r.link ?? '')}" target="_blank" rel="noopener" class="ref-link-anchor">${esc(r.link ?? r.site?.name ?? 'link')}</a>
${meta ? `<span class="ref-meta">${esc(meta)}</span>` : ''}`;
refsModalList.appendChild(item); refsModalList.appendChild(item);
}); });
} }
} catch (e) {
refsModalList.innerHTML = `<p style="color:#eb5757;font-size:.875rem">${esc(e.message)}</p>`; refsModal.showModal();
}
} }
// ── Call / Learn mode ──────────────────────────────────────────── // ── Call / Learn mode ────────────────────────────────────────────
@ -1143,7 +1008,6 @@ function callSelect(value, modal) {
} }
$('btn-call').addEventListener('click', () => openCallModal('callable')); $('btn-call').addEventListener('click', () => openCallModal('callable'));
$('btn-review').addEventListener('click', () => openCallModal('review'));
$('btn-learn').addEventListener('click', () => openCallModal('to_learn')); $('btn-learn').addEventListener('click', () => openCallModal('to_learn'));
$('call-close').addEventListener('click', () => callModal.close()); $('call-close').addEventListener('click', () => callModal.close());
$('call-back').addEventListener('click', () => { $('call-back').addEventListener('click', () => {
@ -1222,8 +1086,8 @@ function getBulkRefsFiltered() {
if (search && !(t.name ?? '').toLowerCase().includes(search)) return false; if (search && !(t.name ?? '').toLowerCase().includes(search)) return false;
if (srcId && t.source_id !== parseInt(srcId)) 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 (instId && !t.instruments.some(e => e.instrument_id === parseInt(instId))) return false;
if (hasRefs === 'no' && (t.reference_count ?? 0) > 0) return false; if (hasRefs === 'no' && t.references.length > 0) return false;
if (hasRefs === 'yes' && (t.reference_count ?? 0) === 0) return false; if (hasRefs === 'yes' && t.references.length === 0) return false;
return true; return true;
}); });
@ -1260,7 +1124,7 @@ function renderBulkRefsList() {
row.dataset.tuneId = tune.id; row.dataset.tuneId = tune.id;
const instruments = tune.instruments.map(e => e.instrument_name).filter(Boolean).join(', '); const instruments = tune.instruments.map(e => e.instrument_name).filter(Boolean).join(', ');
const refCount = tune.reference_count ?? 0; const refCount = tune.references.length;
const refPill = refCount > 0 const refPill = refCount > 0
? `<span class="pill pill-yes">${refCount} ref${refCount !== 1 ? 's' : ''}</span>` ? `<span class="pill pill-yes">${refCount} ref${refCount !== 1 ? 's' : ''}</span>`
: `<span class="pill" style="background:#222">0</span>`; : `<span class="pill" style="background:#222">0</span>`;
@ -1430,30 +1294,12 @@ function parseCSVLine(line) {
} }
function parseCSV(text) { function parseCSV(text) {
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n'); const lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());
if (lines.length < 2) return [];
// Split into rows respecting quoted multiline fields const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/\s+/g, '_'));
const rows = []; return lines.slice(1)
let cur = '', inQ = false; .map(line => {
for (let i = 0; i < text.length; i++) { const vals = parseCSVLine(line);
const ch = text[i];
if (ch === '"') {
if (inQ && text[i + 1] === '"') { cur += '""'; i++; }
else { inQ = !inQ; cur += ch; }
} else if (ch === '\n' && !inQ) {
rows.push(cur); cur = '';
} else {
cur += ch;
}
}
if (cur.trim()) rows.push(cur);
if (rows.length < 2) return [];
const headers = parseCSVLine(rows[0]).map(h => h.trim().toLowerCase().replace(/\s+/g, '_'));
return rows.slice(1)
.filter(r => r.trim())
.map(r => {
const vals = parseCSVLine(r);
const row = {}; const row = {};
headers.forEach((h, i) => { row[h] = (vals[i] ?? '').trim(); }); headers.forEach((h, i) => { row[h] = (vals[i] ?? '').trim(); });
return row; return row;
@ -1467,7 +1313,6 @@ async function importCSVRows(rows) {
const tunCache = new Map(); // name.lower → id (tunings) const tunCache = new Map(); // name.lower → id (tunings)
const musCache = new Map(); // name.lower → id (musicians) const musCache = new Map(); // name.lower → id (musicians)
const instCache = new Map(); // name.lower → id (instruments) 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 tuneCache = new Map(); // name.lower → id (tunes created this run)
const results = { created: 0, skipped: 0, errors: [] }; const results = { created: 0, skipped: 0, errors: [] };
@ -1491,7 +1336,6 @@ async function importCSVRows(rows) {
const tuningId = await resolveOrCreate('/tunings/', allTunings, tunCache, row.tuning?.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 learnedFromId = await resolveOrCreate('/musicians/', allMusicians, musCache, row.learned_from?.trim() || null);
const instId = await resolveOrCreate('/instruments', allInstruments, instCache, row.instrument?.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 // Find existing tune or create new
const lower = tuneName.toLowerCase(); const lower = tuneName.toLowerCase();
@ -1509,173 +1353,26 @@ async function importCSVRows(rows) {
} }
// Add instrument entry (ignore 409 = already exists for that instrument) // Add instrument entry (ignore 409 = already exists for that instrument)
// Capture the returned entry so we can attach an instrument note
let entryId = null;
if (instId) { if (instId) {
const status = ['callable', 'review', 'to_learn'].includes(row.status?.trim()) ? row.status.trim() : null; 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; const difficulty = ['easy', 'hard'].includes(row.difficulty?.trim()?.toLowerCase()) ? row.difficulty.trim().toLowerCase() : null;
try { try {
const entry = await apiFetch(`/tunes/${tuneId}/instruments`, { await apiFetch(`/tunes/${tuneId}/instruments`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ body: JSON.stringify({
instrument_id: instId, instrument_id: instId,
tuning_id: tuningId, tuning_id: tuningId,
term_id: termId,
learned_from_id: learnedFromId, learned_from_id: learnedFromId,
date_learned: row.date_learned?.trim() || null, date_learned: row.date_learned?.trim() || null,
status, status,
difficulty, difficulty,
}), }),
}); });
entryId = entry.id;
} catch (e) { } catch (e) {
if (!e.message.startsWith('409')) throw e; if (!e.message.startsWith('409')) throw e;
} }
} }
// Tune-level note
const tuneNote = row.tune_note?.trim();
if (tuneNote) {
await apiFetch(`/tunes/${tuneId}/notes`, {
method: 'POST', body: JSON.stringify({ note: tuneNote }),
});
}
// Instrument-level note (only if we just created the entry)
const instNote = row.instrument_note?.trim();
if (instNote && entryId) {
await apiFetch(`/tunes/${tuneId}/instruments/${entryId}/notes`, {
method: 'POST', body: JSON.stringify({ note: instNote }),
});
}
results.created++;
} catch (e) {
results.errors.push({ name: tuneName, error: e.message });
}
}
return results;
}
// ── References CSV upload ──────────────────────────────────
const refsCsvFileInput = $('refs-csv-file-input');
$('btn-upload-refs-csv').addEventListener('click', () => {
bulkEditPopover.hidden = true;
openUploadRefsCsvModal();
});
refsCsvFileInput.addEventListener('change', () => {
$('btn-refs-csv-import').disabled = !refsCsvFileInput.files.length;
$('refs-csv-results').hidden = true;
$('refs-csv-results').innerHTML = '';
});
$('btn-refs-csv-cancel').addEventListener('click', () => $('upload-refs-csv-modal').close());
$('btn-refs-csv-import').addEventListener('click', async () => {
const file = refsCsvFileInput.files[0];
if (!file) return;
$('btn-refs-csv-import').disabled = true;
$('btn-refs-csv-cancel').textContent = 'Close';
const resultsEl = $('refs-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 importRefsCsvRows(rows);
await loadAll();
let html = `<p class="csv-ok">✓ ${results.created} reference${results.created !== 1 ? 's' : ''} imported.</p>`;
if (results.warnings.length) {
html += `<p class="csv-error">${results.warnings.length} skipped:</p><ul class="csv-errors">`;
results.warnings.forEach(w => { html += `<li>${esc(w)}</li>`; });
html += '</ul>';
}
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-refs-csv-import').disabled = false;
}
});
function openUploadRefsCsvModal() {
refsCsvFileInput.value = '';
$('btn-refs-csv-import').disabled = true;
$('btn-refs-csv-cancel').textContent = 'Cancel';
$('refs-csv-results').hidden = true;
$('refs-csv-results').innerHTML = '';
$('refs-csv-format-details').open = true;
$('upload-refs-csv-modal').showModal();
}
async function importRefsCsvRows(rows) {
const siteCache = new Map();
const musCache = new Map();
const results = { created: 0, warnings: [], errors: [] };
async function resolve(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.tune_name?.trim();
const sourceName = row.source?.trim();
if (!tuneName) continue;
const matches = allTunes.filter(t => (t.name ?? '').toLowerCase() === tuneName.toLowerCase());
let tune;
if (matches.length === 0) {
results.warnings.push(`${tuneName} — not found`);
continue;
} else if (matches.length === 1) {
tune = matches[0];
} else {
// Multiple tunes with this name — need source to disambiguate
if (sourceName) {
tune = matches.find(t => (t.source?.name ?? '').toLowerCase() === sourceName.toLowerCase());
if (!tune) {
results.warnings.push(`${tuneName} (source: ${sourceName}) — no matching source found`);
continue;
}
} else {
results.warnings.push(`${tuneName} — ambiguous (${matches.length} tunes with this name; add a source column to disambiguate)`);
continue;
}
}
try {
const link = row.link?.trim() || null;
const siteId = await resolve('/reference_sites/', allReferenceSites, siteCache, row.site?.trim() || null);
const musicians = [];
for (const key of ['musician1', 'musician2', 'musician3', 'musician4']) {
const name = row[key]?.trim();
if (!name) continue;
const id = await resolve('/musicians/', allMusicians, musCache, name);
if (id) musicians.push({ id });
}
if (!link && !siteId && musicians.length === 0) continue;
await apiFetch(`/tunes/${tune.id}/references`, {
method: 'POST',
body: JSON.stringify({ link, site_id: siteId, musicians }),
});
results.created++; results.created++;
} catch (e) { } catch (e) {
results.errors.push({ name: tuneName, error: e.message }); results.errors.push({ name: tuneName, error: e.message });
@ -1721,7 +1418,6 @@ function attachTabAutocomplete(input, datalist) {
attachTabAutocomplete(sourceInput, sourceDatalist); attachTabAutocomplete(sourceInput, sourceDatalist);
attachTabAutocomplete(tuneForm.elements.inst_tuning, tuningDatalist); attachTabAutocomplete(tuneForm.elements.inst_tuning, tuningDatalist);
attachTabAutocomplete(tuneForm.elements.inst_term, termDatalist);
attachTabAutocomplete(tuneForm.elements.inst_learned_from, musicianDatalist); attachTabAutocomplete(tuneForm.elements.inst_learned_from, musicianDatalist);
// ── Boot ───────────────────────────────────────────────────── // ── Boot ─────────────────────────────────────────────────────

View file

@ -14,9 +14,8 @@
<div id="header-actions"> <div id="header-actions">
<button id="btn-clear-filters" hidden>✕ Clear filters</button> <button id="btn-clear-filters" hidden>✕ Clear filters</button>
<button id="btn-call">Call</button> <button id="btn-call">Call</button>
<button id="btn-review">Review</button>
<button id="btn-learn">Learn</button> <button id="btn-learn">Learn</button>
<button id="btn-add-tune" hidden>+ Add</button> <button id="btn-add-tune" hidden>+ Add tune</button>
<button id="btn-unlock">🔒 Unlock</button> <button id="btn-unlock">🔒 Unlock</button>
<button id="btn-bulk-edit" hidden></button> <button id="btn-bulk-edit" hidden></button>
</div> </div>
@ -51,8 +50,7 @@
<!-- ── Bulk edit popover ─────────────────────────────────────── --> <!-- ── Bulk edit popover ─────────────────────────────────────── -->
<div id="bulk-edit-popover" hidden> <div id="bulk-edit-popover" hidden>
<button id="btn-open-bulk-refs">📎 Bulk Edit References</button> <button id="btn-open-bulk-refs">📎 Bulk Edit References</button>
<button id="btn-upload-csv">📂 Upload Tunes from CSV</button> <button id="btn-upload-csv">📂 Upload from CSV</button>
<button id="btn-upload-refs-csv">🔗 Upload References from CSV</button>
</div> </div>
<!-- ── Bulk edit references modal ───────────────────────────── --> <!-- ── Bulk edit references modal ───────────────────────────── -->
@ -122,14 +120,6 @@
<button type="button" class="quick-add-btn" id="btn-quick-add-tuning" title="Add new tuning">+</button> <button type="button" class="quick-add-btn" id="btn-quick-add-tuning" title="Add new tuning">+</button>
</span> </span>
</label> </label>
<label>Term
<span class="autocomplete-row">
<input name="inst_term" type="text"
list="term-list" placeholder="— none —" autocomplete="off" />
<datalist id="term-list"></datalist>
<button type="button" class="quick-add-btn" id="btn-quick-add-term" title="Add new term">+</button>
</span>
</label>
<label>Learned from <label>Learned from
<span class="autocomplete-row"> <span class="autocomplete-row">
<input name="inst_learned_from" type="text" <input name="inst_learned_from" type="text"
@ -163,7 +153,6 @@
<button type="button" id="btn-add-ref">+ reference</button> <button type="button" id="btn-add-ref">+ reference</button>
</div> </div>
<p id="tune-error" class="modal-error" hidden></p>
<div class="modal-actions"> <div class="modal-actions">
<button type="button" id="btn-delete-tune" class="btn-danger" hidden>Delete tune</button> <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-edit-notes" hidden>📝 Notes</button>
@ -182,7 +171,7 @@
</div> </div>
</dialog> </dialog>
<!-- ── Notes modal ───────────────────────────────── --> <!-- ── Notes modal ──────────────────────────────────────────── -->
<dialog id="notes-modal"> <dialog id="notes-modal">
<h2 id="notes-modal-title">Notes</h2> <h2 id="notes-modal-title">Notes</h2>
<div id="tune-notes-section"> <div id="tune-notes-section">
@ -200,16 +189,6 @@
</div> </div>
</dialog> </dialog>
<!-- ── Note editor modal (fullscreen, separate from notes list) ─ -->
<dialog id="note-editor-modal">
<h2 id="note-editor-title">Add note</h2>
<textarea id="note-editor-ta" placeholder="Write your note… (Ctrl+↵ to save)"></textarea>
<div class="modal-actions">
<button id="btn-note-save">Save note</button>
<button id="btn-note-cancel">Cancel</button>
</div>
</dialog>
<!-- ── Call modal ─────────────────────────────────────────── --> <!-- ── Call modal ─────────────────────────────────────────── -->
<dialog id="call-modal"> <dialog id="call-modal">
<div id="call-header"> <div id="call-header">
@ -239,9 +218,6 @@
<tr><td>date_learned</td><td></td><td>YYYY-MM-DD</td></tr> <tr><td>date_learned</td><td></td><td>YYYY-MM-DD</td></tr>
<tr><td>status</td><td></td><td>callable / review / to_learn</td></tr> <tr><td>status</td><td></td><td>callable / review / to_learn</td></tr>
<tr><td>difficulty</td><td></td><td>easy / hard</td></tr> <tr><td>difficulty</td><td></td><td>easy / hard</td></tr>
<tr><td>term</td><td></td><td>Created if new</td></tr>
<tr><td>tune_note</td><td></td><td>One note on the tune</td></tr>
<tr><td>instrument_note</td><td></td><td>One note on the instrument entry</td></tr>
</tbody> </tbody>
</table> </table>
<p class="csv-format-note">If a tune with the same name already exists a new instrument entry is added to it. Sources, tunings, and musicians are created automatically if they dont exist.</p> <p class="csv-format-note">If a tune with the same name already exists a new instrument entry is added to it. Sources, tunings, and musicians are created automatically if they dont exist.</p>
@ -256,37 +232,6 @@
</div> </div>
</dialog> </dialog>
<!-- ── References CSV upload modal ───────────────────────────── -->
<dialog id="upload-refs-csv-modal">
<h2>Upload References from CSV</h2>
<details id="refs-csv-format-details" open>
<summary>Expected format</summary>
<p style="font-size:.85rem;margin:.5rem 0 .4rem">One reference per row. Tune matched by name — must already exist.</p>
<table class="csv-format-table">
<thead><tr><th>Column</th><th>Required</th><th>Notes</th></tr></thead>
<tbody>
<tr><td>tune_name</td><td>Yes</td><td>Must match an existing tune (case-insensitive)</td></tr>
<tr><td>source</td><td></td><td>Disambiguates when multiple tunes share a name</td></tr>
<tr><td>link</td><td></td><td>URL</td></tr>
<tr><td>site</td><td></td><td>Site name — created if new</td></tr>
<tr><td>musician1</td><td></td><td>Musician name — created if new</td></tr>
<tr><td>musician2</td><td></td><td></td></tr>
<tr><td>musician3</td><td></td><td></td></tr>
<tr><td>musician4</td><td></td><td></td></tr>
</tbody>
</table>
<p class="csv-format-note">Rows where the tune name is not found are skipped with a warning in the results.</p>
</details>
<div id="refs-csv-upload-area">
<input type="file" id="refs-csv-file-input" accept=".csv,.tsv,text/csv" />
</div>
<div id="refs-csv-results" hidden></div>
<div class="modal-actions">
<button id="btn-refs-csv-import" disabled>Import</button>
<button id="btn-refs-csv-cancel">Cancel</button>
</div>
</dialog>
<!-- Tooltip for truncated mobile text --> <!-- Tooltip for truncated mobile text -->
<div id="mob-tooltip" hidden></div> <div id="mob-tooltip" hidden></div>

View file

@ -29,7 +29,6 @@ header {
padding: .75rem 1.25rem; padding: .75rem 1.25rem;
display: flex; display: flex;
align-items: center; align-items: center;
flex-wrap: wrap;
gap: .75rem; gap: .75rem;
} }
@ -40,11 +39,8 @@ header h1 { font-size: 1.4rem; white-space: nowrap; }
gap: .6rem; gap: .6rem;
align-items: center; align-items: center;
flex-shrink: 0; flex-shrink: 0;
margin-left: auto; /* right-aligns on same line; left-fills when alone on a row */
} }
#filter-search { #filter-search {
flex: 1; flex: 1;
background: var(--bg); background: var(--bg);
@ -232,10 +228,8 @@ th.sort-desc .sort-indicator::after { content: '▼'; }
text-overflow: ellipsis; text-overflow: ellipsis;
white-space: nowrap; white-space: nowrap;
} }
.cell-name-content { max-width: min(14vw, 200px); } .cell-name-content { max-width: min(22vw, 260px); }
.cell-source-content { max-width: min(11vw, 160px); } .cell-source-content { max-width: min(26vw, 200px); }
.cell-inst-content { max-width: min(11vw, 130px); }
.cell-tuning-content { max-width: min(9vw, 110px); }
.pill { .pill {
display: inline-block; display: inline-block;
@ -261,9 +255,6 @@ th.sort-desc .sort-indicator::after { content: '▼'; }
/* ── Modal ────────────────────────────────────────────────── */ /* ── Modal ────────────────────────────────────────────────── */
dialog { dialog {
position: fixed;
inset: 0;
margin: auto;
background: var(--surface); background: var(--surface);
color: var(--text); color: var(--text);
border: 1px solid var(--border); border: 1px solid var(--border);
@ -305,8 +296,6 @@ fieldset label {
font-size: .85rem; font-size: .85rem;
} }
fieldset input[type="checkbox"] { justify-self: start; }
fieldset input[type="text"], fieldset input[type="text"],
fieldset input[type="date"], fieldset input[type="date"],
fieldset select { fieldset select {
@ -359,8 +348,6 @@ fieldset.active .fs-body { display: contents; }
.btn-danger { background: var(--accent); } .btn-danger { background: var(--accent); }
.modal-error { color: #eb5757; font-size: .82rem; margin: .3rem 0 0; }
.note-row { .note-row {
display: flex; display: flex;
align-items: center; align-items: center;
@ -369,7 +356,7 @@ fieldset.active .fs-body { display: contents; }
border-bottom: 1px solid var(--border); border-bottom: 1px solid var(--border);
} }
.note-row:last-child { border-bottom: none; } .note-row:last-child { border-bottom: none; }
.note-text { flex: 1; font-size: .875rem; white-space: pre-wrap; word-break: break-word; } .note-text { flex: 1; font-size: .875rem; }
.note-edit-controls { display: flex; gap: .25rem; } .note-edit-controls { display: flex; gap: .25rem; }
.btn-edit-row { background: transparent; border: none; font-size: 1rem; padding: .2rem .3rem; } .btn-edit-row { background: transparent; border: none; font-size: 1rem; padding: .2rem .3rem; }
@ -405,43 +392,6 @@ fieldset.active .fs-body { display: contents; }
#btn-save-tune { background: var(--accent); } #btn-save-tune { background: var(--accent); }
#btn-cancel { background: transparent; border: 1px solid var(--border); } #btn-cancel { background: transparent; border: 1px solid var(--border); }
#notes-modal {
max-width: 700px;
height: 70vh;
max-height: 85vh;
}
#note-editor-modal {
max-width: 700px;
height: 90vh;
max-height: 95vh;
}
#note-editor-modal[open] {
display: flex;
flex-direction: column;
}
#note-editor-ta {
flex: 1;
min-height: 0;
display: block;
width: 100%;
resize: vertical;
background: var(--bg);
border: 1px solid var(--border);
color: var(--text);
border-radius: var(--radius);
padding: .5rem .65rem;
font-size: .875rem;
font-family: var(--font);
line-height: 1.5;
margin-bottom: .5rem;
}
#note-editor-ta:focus {
outline: 2px solid var(--accent2);
outline-offset: -1px;
}
/* ── Detail modal ─────────────────────────────────────────── */ /* ── Detail modal ─────────────────────────────────────────── */
#detail-content h2 { margin-bottom: .75rem; } #detail-content h2 { margin-bottom: .75rem; }
#detail-content table { width: 100%; border-collapse: collapse; font-size: .85rem; } #detail-content table { width: 100%; border-collapse: collapse; font-size: .85rem; }
@ -457,16 +407,11 @@ fieldset.active .fs-body { display: contents; }
/* ── Responsive ───────────────────────────────────────────── */ /* ── Responsive ───────────────────────────────────────────── */
@media (max-width: 600px) { @media (max-width: 600px) {
/* Fixed viewport so header and footer stay put while main scrolls */ /* Header: h1 + actions on line 1, search full-width on line 2 */
body { height: 100dvh; overflow: hidden; } header { flex-wrap: wrap; }
main { overflow-y: auto; } header h1 { order: 1; flex: 1; }
#header-actions { order: 2; flex-shrink: 0; }
/* Search drops to its own full-width row */
#filter-search { order: 3; flex-basis: 100%; } #filter-search { order: 3; flex-basis: 100%; }
/* Actions fill remaining space; each button gets an equal share; ☰ stays natural size */
#header-actions { flex: 1; margin-left: 0; }
#header-actions > button { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; }
#btn-bulk-edit { flex: none; }
/* Table: block layout so the card has a concrete width to truncate against */ /* Table: block layout so the card has a concrete width to truncate against */
#tune-table, #tune-table,
@ -531,17 +476,17 @@ fieldset.active .fs-body { display: contents; }
} }
.mob-actions { display: flex; gap: .2rem; flex-shrink: 0; } .mob-actions { display: flex; gap: .2rem; flex-shrink: 0; }
/* Modals: fullscreen — !important beats any ID-specific desktop size rules */ /* Modals: fullscreen */
dialog { dialog {
position: fixed !important; position: fixed;
inset: 0 !important; inset: 0;
width: 100% !important; width: 100%;
max-width: 100% !important; max-width: 100%;
height: 100% !important; height: 100%;
max-height: 100% !important; max-height: 100%;
border-radius: 0 !important; border-radius: 0;
border: none !important; border: none;
margin: 0 !important; margin: 0;
} }
} }
@ -574,8 +519,7 @@ fieldset.active .fs-body { display: contents; }
#btn-bulk-edit { background: transparent; border: 1px solid var(--border); color: var(--text); font-size: 1rem; padding: .3rem .65rem; } #btn-bulk-edit { background: transparent; border: 1px solid var(--border); color: var(--text); font-size: 1rem; padding: .3rem .65rem; }
#btn-call { background: #1b4332; color: #74c69d; border: 1px solid #2d6a4f; font-weight: 600; } #btn-call { background: #1b4332; color: #74c69d; border: 1px solid #2d6a4f; font-weight: 600; }
#btn-review { background: #3d2e10; color: #f2c94c; border: 1px solid #4d3a1e; font-weight: 600; } #btn-learn { background: #3d2e10; color: #f2c94c; border: 1px solid #4d3a1e; font-weight: 600; }
#btn-learn { background: #2a1a4e; color: #c4b5fd; border: 1px solid #3d2562; font-weight: 600; }
/* ── Call modal ───────────────────────────────────────────── */ /* ── Call modal ───────────────────────────────────────────── */
#call-modal { #call-modal {
@ -839,6 +783,7 @@ fieldset.active .fs-body { display: contents; }
.ref-link-anchor { .ref-link-anchor {
color: var(--accent); color: var(--accent);
font-size: .875rem; font-size: .875rem;
word-break: break-all;
text-decoration: none; text-decoration: none;
} }
.ref-link-anchor:hover { text-decoration: underline; } .ref-link-anchor:hover { text-decoration: underline; }