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 Shared Tasks
- [x] Add small table/list renderer util in `script.js` to build rows safely (uses `escapeHtml`). Implemented via `createTableRenderer`. - [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`. - [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). - [x] Add common keyboard handlers: Enter to open selection; Esc to close overlays (already wired globally). Implemented via shared `Keyboard` helper in `script.js`.
- [ ] Add loading/empty-state helpers for lists. - [x] Add loading/empty-state helpers for lists. Implemented via `createAsyncListState` utility.
Primary Navigation (Hub) Primary Navigation (Hub)
- [x] `tpl-nav` (base): Landing hub to choose "Search", "Browse Artists", "Browse Albums", "Browse Years", "Browse Genres", "Stats". - [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] `tpl-browse-artists` (base): Alphabetical list, AZ quick jump, mini filter box; paginated.
- [x] `createBrowseArtistsView(db)`: Loads pages; clicking row opens Artist overlay. - [x] `createBrowseArtistsView(db)`: Loads pages; clicking row opens Artist overlay.
- SQL: `SELECT id, name FROM artists ORDER BY name LIMIT ? OFFSET ?`. - 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 Browse Albums
- [x] `tpl-browse-albums` (base): List or grid with title, artist badge, year; paginated and sortable by artist/year/title. - [x] `tpl-browse-albums` (base): List or grid with title, artist badge, year; paginated and sortable by artist/year/title.

288
script.js
View file

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