Compare commits
No commits in common. "9dc16c6c637c596e0c3083d6bcf65597d916ce2b" and "3810d1c05cdd26c8fcb646302854754deaeefdb6" have entirely different histories.
9dc16c6c63
...
3810d1c05c
3 changed files with 138 additions and 552 deletions
534
app.js
534
app.js
|
|
@ -10,19 +10,16 @@ let allMusicians = [];
|
|||
let allInstruments = [];
|
||||
let allTunings = [];
|
||||
let allReferenceSites = [];
|
||||
let allTerms = [];
|
||||
let sortCol = 'name';
|
||||
let sortDir = 'asc';
|
||||
|
||||
// editing state
|
||||
let editingTuneId = null; // null = add mode
|
||||
let editingEntryId = null; // null = no existing instrument entry
|
||||
let editingOriginalRefIds = []; // ref IDs present when edit modal opened
|
||||
let editingTuneId = null; // null = add mode
|
||||
let editingEntryId = null; // null = no existing instrument entry
|
||||
|
||||
// notes modal state
|
||||
let notesTuneId = null;
|
||||
let notesEntryId = null;
|
||||
let noteEditorState = null; // { type: 'tune'|'inst', noteId: null|number }
|
||||
let notesTuneId = null;
|
||||
let notesEntryId = null;
|
||||
|
||||
let unlocked = false;
|
||||
|
||||
|
|
@ -44,7 +41,6 @@ const sourceDatalist = $('source-list');
|
|||
const musicianDatalist = $('musician-list');
|
||||
const instrumentSelect = $('instrument-select');
|
||||
const tuningDatalist = $('tuning-list');
|
||||
const termDatalist = $('term-list');
|
||||
const btnDeleteTune = $('btn-delete-tune');
|
||||
const btnEditNotes = $('btn-edit-notes');
|
||||
const btnBulkEdit = $('btn-bulk-edit');
|
||||
|
|
@ -118,20 +114,18 @@ function setStatus(msg, isError = false) {
|
|||
async function loadAll() {
|
||||
setStatus('Loading…');
|
||||
try {
|
||||
[allTunes, allSources, allMusicians, allInstruments, allTunings, allReferenceSites, allTerms] = await Promise.all([
|
||||
[allTunes, allSources, allMusicians, allInstruments, allTunings, allReferenceSites] = await Promise.all([
|
||||
apiFetch('/tunes/'),
|
||||
apiFetch('/sources/'),
|
||||
apiFetch('/musicians/'),
|
||||
apiFetch('/instruments'),
|
||||
apiFetch('/tunings/'),
|
||||
apiFetch('/reference_sites/'),
|
||||
apiFetch('/terms/'),
|
||||
]);
|
||||
populateFilterOptions();
|
||||
populateDatalist(sourceDatalist, allSources);
|
||||
populateDatalist(musicianDatalist, allMusicians);
|
||||
populateDatalist(tuningDatalist, allTunings);
|
||||
populateDatalist(termDatalist, allTerms);
|
||||
populateDatalist(refSiteDatalist, allReferenceSites);
|
||||
populateInstrumentSelect();
|
||||
renderTable();
|
||||
|
|
@ -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 () => {
|
||||
const item = await quickAdd('/instruments', 'New instrument name:', allInstruments, null);
|
||||
if (item) {
|
||||
|
|
@ -241,11 +229,6 @@ function resolveTuningId(name) {
|
|||
return allTunings.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null;
|
||||
}
|
||||
|
||||
function resolveTermId(name) {
|
||||
if (!name) return null;
|
||||
return allTerms.find(x => x.name.toLowerCase() === name.toLowerCase())?.id ?? null;
|
||||
}
|
||||
|
||||
// ── Rows: one per instrument entry ────────────────────────────
|
||||
// For tunes with no instrument entries, emit one row with empty instrument cols
|
||||
function buildRows(tunes) {
|
||||
|
|
@ -433,7 +416,7 @@ function renderTable() {
|
|||
emptyMsg.hidden = rows.length > 0;
|
||||
|
||||
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 ──────────────────────────────────
|
||||
const nameRaw = tune.name ?? '';
|
||||
|
|
@ -448,7 +431,7 @@ function renderTable() {
|
|||
const mobPills = entry ? pillStatus(entry.status, 'mob-pill') : '';
|
||||
const mobActions = [
|
||||
hasNotes ? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>` : '',
|
||||
(tune.reference_count ?? 0) > 0 ? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}">🔗</button>` : '',
|
||||
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>`,
|
||||
].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-source-content mob-truncatable" data-full="${esc(sourceRaw)}" title="${esc(sourceRaw)}">${esc(sourceRaw)}</span></td>
|
||||
<td>${esc(keyStr)}</td>
|
||||
<td><span class="cell-trunc cell-inst-content" title="${esc(entry?.instrument_name ?? '')}">${esc(entry?.instrument_name ?? '')}</span></td>
|
||||
<td><span class="cell-trunc cell-tuning-content" title="${esc(entry?.tuning ?? '')}">${esc(entry?.tuning ?? '')}</span></td>
|
||||
<td>${esc(entry?.instrument_name ?? '')}</td>
|
||||
<td>${esc(entry?.tuning ?? '')}</td>
|
||||
<td>${pillStatus(entry?.status)}</td>
|
||||
<td>${hasNotes
|
||||
? `<button class="action-btn btn-notes" data-tune-id="${tune.id}" data-entry-id="${entry?.id ?? ''}">📝</button>`
|
||||
: ''}</td>
|
||||
<td>${(tune.reference_count ?? 0) > 0
|
||||
<td>${tune.references?.length > 0
|
||||
? `<button class="action-btn btn-show-refs" data-tune-id="${tune.id}" title="References">🔗</button>`
|
||||
: ''}</td>
|
||||
<td><button class="action-btn btn-edit-row"
|
||||
|
|
@ -516,10 +499,8 @@ function pillStatus(status, extraClass = '') {
|
|||
btnAddTune.addEventListener('click', openAdd);
|
||||
|
||||
function openAdd() {
|
||||
editingTuneId = null;
|
||||
editingEntryId = null;
|
||||
editingOriginalRefIds = [];
|
||||
setTuneError('');
|
||||
editingTuneId = null;
|
||||
editingEntryId = null;
|
||||
$('modal-title').textContent = 'Add tune';
|
||||
tuneForm.reset();
|
||||
refsList.innerHTML = '';
|
||||
|
|
@ -531,49 +512,38 @@ function openAdd() {
|
|||
tuneModal.showModal();
|
||||
}
|
||||
|
||||
async function openEdit(tuneId, entryId) {
|
||||
function openEdit(tuneId, entryId) {
|
||||
editingTuneId = tuneId;
|
||||
editingEntryId = entryId;
|
||||
|
||||
// Open modal immediately — name from slim data gives instant feedback
|
||||
const slimTune = allTunes.find(t => t.id === tuneId);
|
||||
$('modal-title').textContent = `Edit: ${slimTune?.name ?? ''}`;
|
||||
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();
|
||||
refsList.innerHTML = '';
|
||||
btnDeleteTune.hidden = false;
|
||||
btnEditNotes.hidden = false;
|
||||
tuneModal.showModal();
|
||||
|
||||
// Fetch full tune for references and confirmed field values
|
||||
let fullTune;
|
||||
try {
|
||||
fullTune = await apiFetch(`/tunes/${tuneId}`);
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
tuneModal.close();
|
||||
return;
|
||||
}
|
||||
|
||||
const entry = entryId ? fullTune.instruments.find(e => e.id === entryId) : null;
|
||||
|
||||
$('modal-title').textContent = `Edit: ${fullTune.name ?? ''}`;
|
||||
tuneForm.elements.name.value = fullTune.name ?? '';
|
||||
tuneForm.elements.key.value = fullTune.key ?? '';
|
||||
tuneForm.elements.modal.checked = !!fullTune.modal;
|
||||
sourceInput.value = fullTune.source?.name ?? '';
|
||||
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_term.value = entry.term ?? '';
|
||||
tuneForm.elements.inst_learned_from.value = entry.learned_from ?? '';
|
||||
tuneForm.elements.inst_date_learned.value = entry.date_learned ?? '';
|
||||
tuneForm.elements.inst_status.value = entry.status ?? '';
|
||||
tuneForm.elements.inst_difficulty.value = entry.difficulty ?? '';
|
||||
}
|
||||
|
||||
editingOriginalRefIds = (fullTune.references ?? []).map(r => r.id);
|
||||
(fullTune.references ?? []).forEach(r => addRefRow(r.link, r.site?.name ?? '', r.id, r.site_id, r.musicians ?? []));
|
||||
// References
|
||||
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) ──────
|
||||
|
|
@ -676,43 +646,11 @@ function addRefRow(link = '', site = '', refId = null, siteId = null, initialMus
|
|||
|
||||
// ── Save ──────────────────────────────────────────────────────
|
||||
$('btn-save-tune').addEventListener('click', saveTune);
|
||||
$('btn-cancel').addEventListener('click', () => { setTuneError(''); tuneModal.close(); });
|
||||
|
||||
function setTuneError(msg) {
|
||||
const el = $('tune-error');
|
||||
el.textContent = msg;
|
||||
el.hidden = !msg;
|
||||
}
|
||||
$('btn-cancel').addEventListener('click', () => tuneModal.close());
|
||||
|
||||
async function saveTune() {
|
||||
const f = tuneForm.elements;
|
||||
|
||||
// Validate required fields
|
||||
const keyVal = f.key.value.trim();
|
||||
const tuningVal = f.inst_tuning.value.trim();
|
||||
|
||||
if (!keyVal) {
|
||||
setTuneError('Key is required.');
|
||||
f.key.focus();
|
||||
return;
|
||||
}
|
||||
if (!instrumentSelect.value) {
|
||||
setTuneError('Instrument is required.');
|
||||
instrumentSelect.focus();
|
||||
return;
|
||||
}
|
||||
if (!tuningVal) {
|
||||
setTuneError('Tuning is required.');
|
||||
f.inst_tuning.focus();
|
||||
return;
|
||||
}
|
||||
if (!resolveTuningId(tuningVal)) {
|
||||
setTuneError(`Tuning "${tuningVal}" not found — use + to add it first.`);
|
||||
f.inst_tuning.focus();
|
||||
return;
|
||||
}
|
||||
setTuneError('');
|
||||
|
||||
const tuneBody = {
|
||||
name: f.name.value || null,
|
||||
key: f.key.value || null,
|
||||
|
|
@ -724,7 +662,6 @@ async function saveTune() {
|
|||
const instBody = instId ? {
|
||||
instrument_id: instId,
|
||||
tuning_id: resolveTuningId(f.inst_tuning.value),
|
||||
term_id: resolveTermId(f.inst_term.value),
|
||||
learned_from_id: resolveMusicianId(f.inst_learned_from.value),
|
||||
date_learned: f.inst_date_learned.value || null,
|
||||
status: f.inst_status.value || null,
|
||||
|
|
@ -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();
|
||||
|
|
@ -831,30 +739,22 @@ async function openNotesModal(tuneId, entryId) {
|
|||
notesTuneId = tuneId;
|
||||
notesEntryId = entryId;
|
||||
|
||||
// Open immediately with name from slim data
|
||||
const slimTune = allTunes.find(t => t.id === tuneId);
|
||||
$('notes-modal-title').textContent = `Notes — ${slimTune?.name ?? ''}`;
|
||||
$('tune-notes-list').innerHTML = '';
|
||||
$('inst-notes-list').innerHTML = '';
|
||||
$('inst-notes-section').hidden = !entryId;
|
||||
$('btn-add-tune-note').hidden = !unlocked;
|
||||
$('btn-add-inst-note').hidden = !unlocked || !entryId;
|
||||
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();
|
||||
|
||||
// Fetch full tune for note data
|
||||
try {
|
||||
const fullTune = await apiFetch(`/tunes/${tuneId}`);
|
||||
const entry = entryId ? fullTune.instruments.find(e => e.id === entryId) : null;
|
||||
|
||||
$('inst-notes-heading').textContent = entry ? `${entry.instrument_name} notes` : 'Instrument notes';
|
||||
$('inst-notes-section').hidden = !entry;
|
||||
$('btn-add-inst-note').hidden = !unlocked || !entry;
|
||||
|
||||
renderTuneNotes(fullTune.notes ?? []);
|
||||
renderInstNotes(entry?.notes ?? []);
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTuneNotes(notes) {
|
||||
|
|
@ -891,62 +791,35 @@ function renderInstNotes(notes) {
|
|||
});
|
||||
}
|
||||
|
||||
$('btn-add-tune-note').addEventListener('click', () => showNoteEditor('tune', null));
|
||||
$('btn-add-inst-note').addEventListener('click', () => showNoteEditor('inst', null));
|
||||
|
||||
function editTuneNote(n) { showNoteEditor('tune', n.id, n.note); }
|
||||
function editInstNote(n) { showNoteEditor('inst', n.id, n.note); }
|
||||
|
||||
function showNoteEditor(type, noteId, existingText = '') {
|
||||
noteEditorState = { type, noteId };
|
||||
$('note-editor-title').textContent = noteId ? 'Edit note' : 'Add note';
|
||||
$('note-editor-ta').value = existingText;
|
||||
$('note-editor-modal').showModal();
|
||||
$('note-editor-ta').focus();
|
||||
}
|
||||
|
||||
function hideNoteEditor() {
|
||||
noteEditorState = null;
|
||||
$('note-editor-ta').value = '';
|
||||
$('note-editor-modal').close();
|
||||
}
|
||||
|
||||
$('btn-note-cancel').addEventListener('click', hideNoteEditor);
|
||||
|
||||
$('note-editor-ta').addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') $('btn-note-save').click();
|
||||
if (e.key === 'Escape') hideNoteEditor();
|
||||
});
|
||||
|
||||
$('btn-note-save').addEventListener('click', async () => {
|
||||
if (!noteEditorState) return;
|
||||
const text = $('note-editor-ta').value.trim();
|
||||
$('btn-add-tune-note').addEventListener('click', async () => {
|
||||
const text = prompt('Note:');
|
||||
if (!text) return;
|
||||
const { type, noteId } = noteEditorState;
|
||||
$('btn-note-save').disabled = true;
|
||||
try {
|
||||
if (type === 'tune') {
|
||||
await apiFetch(
|
||||
noteId ? `/tunes/${notesTuneId}/notes/${noteId}` : `/tunes/${notesTuneId}/notes`,
|
||||
{ method: noteId ? 'PATCH' : 'POST', body: JSON.stringify({ note: text }) }
|
||||
);
|
||||
} else {
|
||||
await apiFetch(
|
||||
noteId
|
||||
? `/tunes/${notesTuneId}/instruments/${notesEntryId}/notes/${noteId}`
|
||||
: `/tunes/${notesTuneId}/instruments/${notesEntryId}/notes`,
|
||||
{ method: noteId ? 'PATCH' : 'POST', body: JSON.stringify({ note: text }) }
|
||||
);
|
||||
}
|
||||
hideNoteEditor();
|
||||
await apiFetch(`/tunes/${notesTuneId}/notes`, { method: 'POST', body: JSON.stringify({ note: text }) });
|
||||
await reloadNotesModal();
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
} finally {
|
||||
$('btn-note-save').disabled = false;
|
||||
}
|
||||
} 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 {
|
||||
|
|
@ -955,6 +828,16 @@ async function deleteTuneNote(noteId) {
|
|||
} 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 {
|
||||
|
|
@ -965,20 +848,11 @@ async function deleteInstNote(noteId) {
|
|||
}
|
||||
|
||||
async function reloadNotesModal() {
|
||||
try {
|
||||
const fullTune = await apiFetch(`/tunes/${notesTuneId}`);
|
||||
const entry = notesEntryId ? fullTune.instruments.find(e => e.id === notesEntryId) : null;
|
||||
renderTuneNotes(fullTune.notes ?? []);
|
||||
renderInstNotes(entry?.notes ?? []);
|
||||
// Keep has_notes in sync so the 📝 button stays accurate
|
||||
const idx = allTunes.findIndex(t => t.id === notesTuneId);
|
||||
if (idx >= 0) {
|
||||
allTunes[idx].has_notes = !!fullTune.notes?.length
|
||||
|| fullTune.instruments.some(e => !!e.notes?.length);
|
||||
}
|
||||
} catch (e) {
|
||||
setStatus(e.message, true);
|
||||
}
|
||||
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());
|
||||
|
|
@ -986,38 +860,29 @@ $('btn-notes-close').addEventListener('click', () => notesModal.close());
|
|||
// ── References display modal ────────────────────────────────────
|
||||
$('btn-refs-modal-close').addEventListener('click', () => refsModal.close());
|
||||
|
||||
async function openRefsModal(tuneId) {
|
||||
function openRefsModal(tuneId) {
|
||||
const tune = allTunes.find(t => t.id === tuneId);
|
||||
$('refs-modal-title').textContent = `References — ${tune?.name ?? ''}`;
|
||||
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">Loading…</p>';
|
||||
refsModal.showModal();
|
||||
refsModalList.innerHTML = '';
|
||||
|
||||
try {
|
||||
const refs = await apiFetch(`/tunes/${tuneId}/references`);
|
||||
refsModalList.innerHTML = '';
|
||||
if (refs.length === 0) {
|
||||
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">No references.</p>';
|
||||
} else {
|
||||
refs.forEach(r => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'refs-modal-item';
|
||||
const musicians = r.musicians?.map(m => m.name).join(', ') ?? '';
|
||||
const siteName = r.site?.name ?? '';
|
||||
const meta = [siteName, musicians].filter(Boolean).join(' · ');
|
||||
// Use meta as link text; fall back to hostname so raw URL is never shown
|
||||
let displayText = meta;
|
||||
if (!displayText && r.link) {
|
||||
try { displayText = new URL(r.link).hostname.replace(/^www\./, ''); } catch { displayText = 'link'; }
|
||||
}
|
||||
if (!displayText) displayText = 'link';
|
||||
item.innerHTML = `
|
||||
<a href="${esc(r.link ?? '')}" target="_blank" rel="noopener" class="ref-link-anchor">${esc(displayText)}</a>`;
|
||||
refsModalList.appendChild(item);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
refsModalList.innerHTML = `<p style="color:#eb5757;font-size:.875rem">${esc(e.message)}</p>`;
|
||||
const refs = tune?.references ?? [];
|
||||
if (refs.length === 0) {
|
||||
refsModalList.innerHTML = '<p style="color:var(--muted);font-size:.875rem">No references.</p>';
|
||||
} else {
|
||||
refs.forEach(r => {
|
||||
const item = document.createElement('div');
|
||||
item.className = 'refs-modal-item';
|
||||
const musicians = r.musicians?.map(m => m.name).join(', ') ?? '';
|
||||
const siteName = r.site?.name ?? '';
|
||||
const meta = [siteName, musicians].filter(Boolean).join(' · ');
|
||||
item.innerHTML = `
|
||||
<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);
|
||||
});
|
||||
}
|
||||
|
||||
refsModal.showModal();
|
||||
}
|
||||
|
||||
// ── Call / Learn mode ────────────────────────────────────────────
|
||||
|
|
@ -1142,9 +1007,8 @@ function callSelect(value, modal) {
|
|||
}
|
||||
}
|
||||
|
||||
$('btn-call').addEventListener('click', () => openCallModal('callable'));
|
||||
$('btn-review').addEventListener('click', () => openCallModal('review'));
|
||||
$('btn-learn').addEventListener('click', () => openCallModal('to_learn'));
|
||||
$('btn-call').addEventListener('click', () => openCallModal('callable'));
|
||||
$('btn-learn').addEventListener('click', () => openCallModal('to_learn'));
|
||||
$('call-close').addEventListener('click', () => callModal.close());
|
||||
$('call-back').addEventListener('click', () => {
|
||||
callStep--;
|
||||
|
|
@ -1222,8 +1086,8 @@ function getBulkRefsFiltered() {
|
|||
if (search && !(t.name ?? '').toLowerCase().includes(search)) return false;
|
||||
if (srcId && t.source_id !== parseInt(srcId)) return false;
|
||||
if (instId && !t.instruments.some(e => e.instrument_id === parseInt(instId))) return false;
|
||||
if (hasRefs === 'no' && (t.reference_count ?? 0) > 0) return false;
|
||||
if (hasRefs === 'yes' && (t.reference_count ?? 0) === 0) return false;
|
||||
if (hasRefs === 'no' && t.references.length > 0) return false;
|
||||
if (hasRefs === 'yes' && t.references.length === 0) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
|
|
@ -1260,7 +1124,7 @@ function renderBulkRefsList() {
|
|||
row.dataset.tuneId = tune.id;
|
||||
|
||||
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
|
||||
? `<span class="pill pill-yes">${refCount} ref${refCount !== 1 ? 's' : ''}</span>`
|
||||
: `<span class="pill" style="background:#222">0</span>`;
|
||||
|
|
@ -1430,30 +1294,12 @@ function parseCSVLine(line) {
|
|||
}
|
||||
|
||||
function parseCSV(text) {
|
||||
text = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
|
||||
|
||||
// Split into rows respecting quoted multiline fields
|
||||
const rows = [];
|
||||
let cur = '', inQ = false;
|
||||
for (let i = 0; i < text.length; i++) {
|
||||
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 lines = text.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split('\n').filter(l => l.trim());
|
||||
if (lines.length < 2) return [];
|
||||
const headers = parseCSVLine(lines[0]).map(h => h.trim().toLowerCase().replace(/\s+/g, '_'));
|
||||
return lines.slice(1)
|
||||
.map(line => {
|
||||
const vals = parseCSVLine(line);
|
||||
const row = {};
|
||||
headers.forEach((h, i) => { row[h] = (vals[i] ?? '').trim(); });
|
||||
return row;
|
||||
|
|
@ -1467,7 +1313,6 @@ async function importCSVRows(rows) {
|
|||
const tunCache = new Map(); // name.lower → id (tunings)
|
||||
const musCache = new Map(); // name.lower → id (musicians)
|
||||
const instCache = new Map(); // name.lower → id (instruments)
|
||||
const termCache = new Map(); // name.lower → id (terms)
|
||||
const tuneCache = new Map(); // name.lower → id (tunes created this run)
|
||||
const results = { created: 0, skipped: 0, errors: [] };
|
||||
|
||||
|
|
@ -1491,7 +1336,6 @@ async function importCSVRows(rows) {
|
|||
const tuningId = await resolveOrCreate('/tunings/', allTunings, tunCache, row.tuning?.trim() || null);
|
||||
const learnedFromId = await resolveOrCreate('/musicians/', allMusicians, musCache, row.learned_from?.trim() || null);
|
||||
const instId = await resolveOrCreate('/instruments', allInstruments, instCache, row.instrument?.trim() || null);
|
||||
const termId = await resolveOrCreate('/terms/', allTerms, termCache, row.term?.trim() || null);
|
||||
|
||||
// Find existing tune or create new
|
||||
const lower = tuneName.toLowerCase();
|
||||
|
|
@ -1509,173 +1353,26 @@ async function importCSVRows(rows) {
|
|||
}
|
||||
|
||||
// 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) {
|
||||
const status = ['callable', 'review', 'to_learn'].includes(row.status?.trim()) ? row.status.trim() : null;
|
||||
const difficulty = ['easy', 'hard'].includes(row.difficulty?.trim()?.toLowerCase()) ? row.difficulty.trim().toLowerCase() : null;
|
||||
try {
|
||||
const entry = await apiFetch(`/tunes/${tuneId}/instruments`, {
|
||||
await apiFetch(`/tunes/${tuneId}/instruments`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({
|
||||
instrument_id: instId,
|
||||
tuning_id: tuningId,
|
||||
term_id: termId,
|
||||
learned_from_id: learnedFromId,
|
||||
date_learned: row.date_learned?.trim() || null,
|
||||
status,
|
||||
difficulty,
|
||||
}),
|
||||
});
|
||||
entryId = entry.id;
|
||||
} catch (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++;
|
||||
} catch (e) {
|
||||
results.errors.push({ name: tuneName, error: e.message });
|
||||
|
|
@ -1721,7 +1418,6 @@ function attachTabAutocomplete(input, datalist) {
|
|||
|
||||
attachTabAutocomplete(sourceInput, sourceDatalist);
|
||||
attachTabAutocomplete(tuneForm.elements.inst_tuning, tuningDatalist);
|
||||
attachTabAutocomplete(tuneForm.elements.inst_term, termDatalist);
|
||||
attachTabAutocomplete(tuneForm.elements.inst_learned_from, musicianDatalist);
|
||||
|
||||
// ── Boot ─────────────────────────────────────────────────────
|
||||
|
|
|
|||
61
index.html
61
index.html
|
|
@ -14,9 +14,8 @@
|
|||
<div id="header-actions">
|
||||
<button id="btn-clear-filters" hidden>✕ Clear filters</button>
|
||||
<button id="btn-call">Call</button>
|
||||
<button id="btn-review">Review</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-bulk-edit" hidden>☰</button>
|
||||
</div>
|
||||
|
|
@ -51,8 +50,7 @@
|
|||
<!-- ── Bulk edit popover ─────────────────────────────────────── -->
|
||||
<div id="bulk-edit-popover" hidden>
|
||||
<button id="btn-open-bulk-refs">📎 Bulk Edit References</button>
|
||||
<button id="btn-upload-csv">📂 Upload Tunes from CSV</button>
|
||||
<button id="btn-upload-refs-csv">🔗 Upload References from CSV</button>
|
||||
<button id="btn-upload-csv">📂 Upload from CSV</button>
|
||||
</div>
|
||||
|
||||
<!-- ── 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>
|
||||
</span>
|
||||
</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
|
||||
<span class="autocomplete-row">
|
||||
<input name="inst_learned_from" type="text"
|
||||
|
|
@ -163,7 +153,6 @@
|
|||
<button type="button" id="btn-add-ref">+ reference</button>
|
||||
</div>
|
||||
|
||||
<p id="tune-error" class="modal-error" hidden></p>
|
||||
<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>
|
||||
|
|
@ -182,7 +171,7 @@
|
|||
</div>
|
||||
</dialog>
|
||||
|
||||
<!-- ── Notes modal ───────────────────────────────── -->
|
||||
<!-- ── Notes modal ──────────────────────────────────────────── -->
|
||||
<dialog id="notes-modal">
|
||||
<h2 id="notes-modal-title">Notes</h2>
|
||||
<div id="tune-notes-section">
|
||||
|
|
@ -200,16 +189,6 @@
|
|||
</div>
|
||||
</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 ─────────────────────────────────────────── -->
|
||||
<dialog id="call-modal">
|
||||
<div id="call-header">
|
||||
|
|
@ -239,9 +218,6 @@
|
|||
<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>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>
|
||||
</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 don’t exist.</p>
|
||||
|
|
@ -256,37 +232,6 @@
|
|||
</div>
|
||||
</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 -->
|
||||
<div id="mob-tooltip" hidden></div>
|
||||
|
||||
|
|
|
|||
95
style.css
95
style.css
|
|
@ -29,7 +29,6 @@ header {
|
|||
padding: .75rem 1.25rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: .75rem;
|
||||
}
|
||||
|
||||
|
|
@ -40,11 +39,8 @@ header h1 { font-size: 1.4rem; white-space: nowrap; }
|
|||
gap: .6rem;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
margin-left: auto; /* right-aligns on same line; left-fills when alone on a row */
|
||||
}
|
||||
|
||||
|
||||
|
||||
#filter-search {
|
||||
flex: 1;
|
||||
background: var(--bg);
|
||||
|
|
@ -232,10 +228,8 @@ th.sort-desc .sort-indicator::after { content: '▼'; }
|
|||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.cell-name-content { max-width: min(14vw, 200px); }
|
||||
.cell-source-content { max-width: min(11vw, 160px); }
|
||||
.cell-inst-content { max-width: min(11vw, 130px); }
|
||||
.cell-tuning-content { max-width: min(9vw, 110px); }
|
||||
.cell-name-content { max-width: min(22vw, 260px); }
|
||||
.cell-source-content { max-width: min(26vw, 200px); }
|
||||
|
||||
.pill {
|
||||
display: inline-block;
|
||||
|
|
@ -261,9 +255,6 @@ th.sort-desc .sort-indicator::after { content: '▼'; }
|
|||
|
||||
/* ── Modal ────────────────────────────────────────────────── */
|
||||
dialog {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
margin: auto;
|
||||
background: var(--surface);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--border);
|
||||
|
|
@ -305,8 +296,6 @@ fieldset label {
|
|||
font-size: .85rem;
|
||||
}
|
||||
|
||||
fieldset input[type="checkbox"] { justify-self: start; }
|
||||
|
||||
fieldset input[type="text"],
|
||||
fieldset input[type="date"],
|
||||
fieldset select {
|
||||
|
|
@ -359,8 +348,6 @@ fieldset.active .fs-body { display: contents; }
|
|||
|
||||
.btn-danger { background: var(--accent); }
|
||||
|
||||
.modal-error { color: #eb5757; font-size: .82rem; margin: .3rem 0 0; }
|
||||
|
||||
.note-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
|
@ -369,7 +356,7 @@ fieldset.active .fs-body { display: contents; }
|
|||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.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; }
|
||||
|
||||
.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-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-content h2 { margin-bottom: .75rem; }
|
||||
#detail-content table { width: 100%; border-collapse: collapse; font-size: .85rem; }
|
||||
|
|
@ -457,16 +407,11 @@ fieldset.active .fs-body { display: contents; }
|
|||
|
||||
/* ── Responsive ───────────────────────────────────────────── */
|
||||
@media (max-width: 600px) {
|
||||
/* Fixed viewport so header and footer stay put while main scrolls */
|
||||
body { height: 100dvh; overflow: hidden; }
|
||||
main { overflow-y: auto; }
|
||||
|
||||
/* Search drops to its own full-width row */
|
||||
#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; }
|
||||
/* Header: h1 + actions on line 1, search full-width on line 2 */
|
||||
header { flex-wrap: wrap; }
|
||||
header h1 { order: 1; flex: 1; }
|
||||
#header-actions { order: 2; flex-shrink: 0; }
|
||||
#filter-search { order: 3; flex-basis: 100%; }
|
||||
|
||||
/* Table: block layout so the card has a concrete width to truncate against */
|
||||
#tune-table,
|
||||
|
|
@ -531,17 +476,17 @@ fieldset.active .fs-body { display: contents; }
|
|||
}
|
||||
.mob-actions { display: flex; gap: .2rem; flex-shrink: 0; }
|
||||
|
||||
/* Modals: fullscreen — !important beats any ID-specific desktop size rules */
|
||||
/* Modals: fullscreen */
|
||||
dialog {
|
||||
position: fixed !important;
|
||||
inset: 0 !important;
|
||||
width: 100% !important;
|
||||
max-width: 100% !important;
|
||||
height: 100% !important;
|
||||
max-height: 100% !important;
|
||||
border-radius: 0 !important;
|
||||
border: none !important;
|
||||
margin: 0 !important;
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
height: 100%;
|
||||
max-height: 100%;
|
||||
border-radius: 0;
|
||||
border: none;
|
||||
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-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: #2a1a4e; color: #c4b5fd; border: 1px solid #3d2562; font-weight: 600; }
|
||||
#btn-learn { background: #3d2e10; color: #f2c94c; border: 1px solid #4d3a1e; font-weight: 600; }
|
||||
|
||||
/* ── Call modal ───────────────────────────────────────────── */
|
||||
#call-modal {
|
||||
|
|
@ -839,6 +783,7 @@ fieldset.active .fs-body { display: contents; }
|
|||
.ref-link-anchor {
|
||||
color: var(--accent);
|
||||
font-size: .875rem;
|
||||
word-break: break-all;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ref-link-anchor:hover { text-decoration: underline; }
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue