From 7ee735c2db9de7ee3dd8970d2d1eb8a2e6a571bd Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 16 Sep 2025 22:56:01 -0500 Subject: [PATCH] Add shared keyboard and list helpers with artist FTS --- AGENTS.md | 6 +- script.js | 304 +++++++++++++++++++++++++++++++++++------------------- 2 files changed, 201 insertions(+), 109 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index cda60d8..8415acf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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, A–Z 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. diff --git a/script.js b/script.js index 5b87675..fcc437c 100644 --- a/script.js +++ b/script.js @@ -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, }); }