Add shared keyboard and list helpers with artist FTS

This commit is contained in:
Jordan Wages 2025-09-16 22:56:01 -05:00
commit 7ee735c2db
2 changed files with 201 additions and 109 deletions

View file

@ -209,8 +209,8 @@ Overview
Shared Tasks
- [x] Add small table/list renderer util in `script.js` to build rows safely (uses `escapeHtml`). Implemented via `createTableRenderer`.
- [x] Add shared pagination component (Prev/Next, page size select). Propagate `LIMIT/OFFSET`. Implemented via `createPagination`.
- [ ] Add common keyboard handlers: Enter to open selection; Esc to close overlays (already wired globally).
- [ ] Add loading/empty-state helpers for lists.
- [x] Add common keyboard handlers: Enter to open selection; Esc to close overlays (already wired globally). Implemented via shared `Keyboard` helper in `script.js`.
- [x] Add loading/empty-state helpers for lists. Implemented via `createAsyncListState` utility.
Primary Navigation (Hub)
- [x] `tpl-nav` (base): Landing hub to choose "Search", "Browse Artists", "Browse Albums", "Browse Years", "Browse Genres", "Stats".
@ -228,7 +228,7 @@ Browse Artists
- [x] `tpl-browse-artists` (base): Alphabetical list, AZ quick jump, mini filter box; paginated.
- [x] `createBrowseArtistsView(db)`: Loads pages; clicking row opens Artist overlay.
- SQL: `SELECT id, name FROM artists ORDER BY name LIMIT ? OFFSET ?`.
- [ ] Optional prefix search using FTS prefix (`WHERE f MATCH 'artist:abc*'`) to accelerate filter.
- [x] Optional prefix search using FTS prefix (`WHERE f MATCH 'artist:abc*'`) to accelerate filter. Browse Artists view now favors FTS when filters include ≥2 characters.
Browse Albums
- [x] `tpl-browse-albums` (base): List or grid with title, artist badge, year; paginated and sortable by artist/year/title.

304
script.js
View file

@ -103,7 +103,7 @@
}
window.addEventListener('keydown', (e) => {
if (e.key === 'Escape' && overlayStack.length) {
if (Keyboard.isEscapeKey(e) && overlayStack.length) {
e.preventDefault();
closeTop();
}
@ -338,6 +338,21 @@
};
}
const Keyboard = (() => {
function isActivationKey(event) {
return event && (event.key === 'Enter' || event.key === ' ');
}
function isEscapeKey(event) {
return event && event.key === 'Escape';
}
return {
isActivationKey,
isEscapeKey,
};
})();
function bindRowActivation(tableEl, handler) {
if (!tableEl || typeof handler !== 'function') return () => {};
function findRow(target) {
@ -354,7 +369,7 @@
handler(row.dataset.rowId, event);
};
const onKeyDown = (event) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
if (!Keyboard.isActivationKey(event)) return;
const row = findRow(event.target);
if (!row) return;
event.preventDefault();
@ -368,6 +383,69 @@
};
}
function createAsyncListState({ table, statusEl, pagination }) {
if (!table || !table.el) throw new Error('table required');
if (!statusEl) throw new Error('statusEl required');
const baseClasses = statusEl.className;
function applyTone(tone) {
statusEl.className = baseClasses;
if (tone === 'error') {
statusEl.classList.remove('has-text-grey');
statusEl.classList.add('has-text-danger');
} else {
if (baseClasses && baseClasses.includes('has-text-danger')) return;
if (!statusEl.classList.contains('has-text-grey') && (!baseClasses || !baseClasses.includes('has-text-danger'))) {
statusEl.classList.add('has-text-grey');
}
statusEl.classList.remove('has-text-danger');
}
}
function showMessage(text, tone) {
applyTone(tone);
statusEl.textContent = text;
statusEl.hidden = false;
table.el.hidden = true;
if (pagination) pagination.setDisabled(true);
}
function showRows({ rows, page, pageSize, total, resultsCount }) {
applyTone();
table.setRows(rows);
statusEl.hidden = true;
table.el.hidden = false;
if (pagination) {
pagination.setState({
page,
pageSize,
total,
resultsCount: resultsCount !== undefined ? resultsCount : rows.length,
});
}
}
return {
showIdle(text) {
showMessage(text, 'default');
},
showLoading(text = 'Loading…') {
showMessage(text, 'default');
},
showError(text) {
showMessage(text, 'error');
},
showEmpty(text) {
table.clear();
showMessage(text, 'default');
},
showRows,
disablePagination() {
if (pagination) pagination.setDisabled(true);
},
};
}
let paginationIdCounter = 0;
function createPagination({
pageSizes = [10, 25, 50, 100],
@ -657,6 +735,8 @@
});
$results.appendChild(pagination.el);
const listState = createAsyncListState({ table, statusEl: $status, pagination });
const state = {
query: initialQuery,
page: 1,
@ -723,13 +803,6 @@
WHERE f MATCH ?
`;
function setStatus(text) {
$status.textContent = text;
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
}
function enableSearch() {
$q.disabled = false;
$q.removeAttribute('aria-disabled');
@ -743,14 +816,11 @@
const term = state.query.trim();
if (!term) {
table.clear();
setStatus('Type to search…');
listState.showIdle('Type to search…');
return;
}
$status.textContent = 'Searching…';
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
listState.showLoading('Searching…');
const offset = (state.page - 1) * state.pageSize;
let total = 0;
@ -766,7 +836,7 @@
if (total === 0) {
table.clear();
$status.textContent = 'No matches found';
listState.showEmpty('No matches found');
return;
}
@ -784,18 +854,15 @@
searchStmt.free();
} catch (err) {
console.error(err);
$status.textContent = 'Search failed. Check console for details.';
listState.showError('Search failed. Check console for details.');
return;
}
table.setRows(rows);
$status.hidden = true;
table.el.hidden = false;
pagination.setState({
listState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
total,
resultsCount: rows.length,
});
}
@ -817,7 +884,7 @@
});
updateSortButtons();
setStatus('Type to search…');
listState.showIdle('Type to search…');
return {
kind: 'base',
@ -902,6 +969,8 @@
});
$results.appendChild(pagination.el);
const listState = createAsyncListState({ table, statusEl: $status, pagination });
const state = {
prefix: initialPrefix,
filter: initialFilter,
@ -913,7 +982,7 @@
return String(str).replace(/[\\%_]/g, (m) => `\\${m}`);
}
function buildTerm() {
function buildLikeTerm() {
const typed = state.filter.trim();
if (typed) return `%${escapeLike(typed)}%`;
if (state.prefix) return `${escapeLike(state.prefix)}%`;
@ -928,12 +997,36 @@
ORDER BY name COLLATE NOCASE
LIMIT ? OFFSET ?
`;
const ftsCountSql = `
SELECT COUNT(*) AS count FROM (
SELECT a.id
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
WHERE f MATCH ?
GROUP BY a.id
) AS matches
`;
const ftsRowsSql = `
SELECT a.id, a.name
FROM fts_tracks f
JOIN tracks t ON t.id = f.rowid
JOIN artists a ON a.id = t.artist_id
WHERE f MATCH ?
GROUP BY a.id
ORDER BY a.name COLLATE NOCASE
LIMIT ? OFFSET ?
`;
function setStatus(text) {
$status.textContent = text;
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
function buildArtistFtsMatch(input) {
const tokens = String(input)
.trim()
.toLowerCase()
.split(/\s+/)
.map((token) => token.replace(/[^0-9a-z]/gi, '').slice(0, 32))
.filter((token) => token.length >= 2);
if (!tokens.length) return null;
return tokens.map((token) => `artist:${token}*`).join(' AND ');
}
function updateJumpButtons() {
@ -946,47 +1039,69 @@
}
function loadArtistsImmediate() {
const term = buildTerm();
$status.textContent = 'Loading…';
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
const typedFilter = state.filter.trim();
const ftsMatch = buildArtistFtsMatch(typedFilter);
const likeTerm = buildLikeTerm();
listState.showLoading('Loading…');
const offset = (state.page - 1) * state.pageSize;
let total = 0;
const rows = [];
try {
const countStmt = db.prepare(countSql);
countStmt.bind([term]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
let usedFts = false;
if (total === 0) {
table.clear();
$status.textContent = 'No artists found';
return;
try {
if (ftsMatch) {
const ftsCountStmt = db.prepare(ftsCountSql);
ftsCountStmt.bind([ftsMatch]);
if (ftsCountStmt.step()) total = Number(ftsCountStmt.getAsObject().count) || 0;
ftsCountStmt.free();
if (total > 0) {
if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize));
const ftsRowsStmt = db.prepare(ftsRowsSql);
ftsRowsStmt.bind([ftsMatch, state.pageSize, (state.page - 1) * state.pageSize]);
while (ftsRowsStmt.step()) rows.push(ftsRowsStmt.getAsObject());
ftsRowsStmt.free();
usedFts = true;
}
}
if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize));
if (!usedFts) {
const countStmt = db.prepare(countSql);
countStmt.bind([likeTerm]);
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
countStmt.free();
const stmt = db.prepare(rowsSql);
stmt.bind([term, state.pageSize, (state.page - 1) * state.pageSize]);
while (stmt.step()) rows.push(stmt.getAsObject());
stmt.free();
if (total === 0) {
table.clear();
listState.showEmpty('No artists found');
return;
}
if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize));
const rowsStmt = db.prepare(rowsSql);
rowsStmt.bind([likeTerm, state.pageSize, (state.page - 1) * state.pageSize]);
while (rowsStmt.step()) rows.push(rowsStmt.getAsObject());
rowsStmt.free();
}
} catch (err) {
console.error(err);
$status.textContent = 'Failed to load artists.';
listState.showError('Failed to load artists.');
return;
}
table.setRows(rows);
$status.hidden = true;
table.el.hidden = false;
pagination.setState({
if (total === 0) {
table.clear();
listState.showEmpty('No artists found');
return;
}
listState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
total,
resultsCount: rows.length,
});
}
@ -1022,7 +1137,7 @@
});
updateJumpButtons();
setStatus('Loading…');
listState.showLoading('Loading…');
loadArtistsImmediate();
return {
@ -1070,6 +1185,8 @@
});
$results.appendChild(pagination.el);
const listState = createAsyncListState({ table, statusEl: $status, pagination });
const state = {
sort: initialSort,
page: 1,
@ -1091,18 +1208,8 @@
LIMIT ? OFFSET ?
`;
function setStatus(text) {
$status.textContent = text;
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
}
function loadAlbumsImmediate() {
$status.textContent = 'Loading…';
$status.hidden = false;
table.el.hidden = true;
pagination.setDisabled(true);
listState.showLoading('Loading…');
let total = 0;
const rows = [];
@ -1113,7 +1220,7 @@
if (total === 0) {
table.clear();
$status.textContent = 'No albums found';
listState.showEmpty('No albums found');
return;
}
@ -1127,18 +1234,15 @@
stmt.free();
} catch (err) {
console.error(err);
$status.textContent = 'Failed to load albums.';
listState.showError('Failed to load albums.');
return;
}
table.setRows(rows);
$status.hidden = true;
table.el.hidden = false;
pagination.setState({
listState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
total,
resultsCount: rows.length,
});
}
@ -1161,7 +1265,7 @@
UX.openOverlay(createAlbumOverlay(db, albumId));
});
setStatus('Loading…');
listState.showLoading('Loading…');
loadAlbumsImmediate();
return {
@ -1217,6 +1321,8 @@
});
$tracksCol.appendChild(pagination.el);
const trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination });
const state = {
years: [],
selectedYear: presetYear,
@ -1308,17 +1414,11 @@
function loadTracksImmediate() {
if (!Number.isFinite(state.selectedYear)) {
trackTable.clear();
trackTable.el.hidden = true;
$tracksStatus.hidden = false;
$tracksStatus.textContent = 'Select a year to view tracks.';
pagination.setDisabled(true);
trackListState.showIdle('Select a year to view tracks.');
return;
}
$tracksStatus.textContent = 'Loading tracks…';
$tracksStatus.hidden = false;
trackTable.el.hidden = true;
pagination.setDisabled(true);
trackListState.showLoading('Loading tracks…');
let total = 0;
const rows = [];
@ -1330,7 +1430,7 @@
if (total === 0) {
trackTable.clear();
$tracksStatus.textContent = 'No tracks recorded for this year.';
trackListState.showEmpty('No tracks recorded for this year.');
return;
}
@ -1343,18 +1443,15 @@
stmt.free();
} catch (err) {
console.error(err);
$tracksStatus.textContent = 'Failed to load tracks.';
trackListState.showError('Failed to load tracks.');
return;
}
trackTable.setRows(rows);
$tracksStatus.hidden = true;
trackTable.el.hidden = false;
pagination.setState({
trackListState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
total,
resultsCount: rows.length,
});
}
@ -1429,6 +1526,8 @@
});
$tracksSection.appendChild(pagination.el);
const trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination });
const state = {
genres: [],
selectedGenre: presetGenre,
@ -1512,18 +1611,14 @@
function loadTracksImmediate() {
if (!state.selectedGenre) {
trackTable.clear();
trackTable.el.hidden = true;
$tracksSection.hidden = true;
pagination.setDisabled(true);
trackListState.showIdle('Select a genre to view tracks.');
return;
}
$tracksSection.hidden = false;
$selectedGenre.textContent = `Tracks tagged ${state.selectedGenre}`;
$tracksStatus.textContent = 'Loading tracks…';
$tracksStatus.hidden = false;
trackTable.el.hidden = true;
pagination.setDisabled(true);
trackListState.showLoading('Loading tracks…');
let total = 0;
const rows = [];
@ -1535,7 +1630,7 @@
if (total === 0) {
trackTable.clear();
$tracksStatus.textContent = 'No tracks found for this genre.';
trackListState.showEmpty('No tracks found for this genre.');
return;
}
@ -1548,18 +1643,15 @@
stmt.free();
} catch (err) {
console.error(err);
$tracksStatus.textContent = 'Failed to load tracks.';
trackListState.showError('Failed to load tracks.');
return;
}
trackTable.setRows(rows);
$tracksStatus.hidden = true;
trackTable.el.hidden = false;
pagination.setState({
trackListState.showRows({
rows,
total,
page: state.page,
pageSize: state.pageSize,
total,
resultsCount: rows.length,
});
}