Add shared keyboard and list helpers with artist FTS
This commit is contained in:
parent
ab7bc36714
commit
7ee735c2db
2 changed files with 201 additions and 109 deletions
|
@ -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.
|
||||
|
|
288
script.js
288
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 = [];
|
||||
let usedFts = false;
|
||||
|
||||
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);
|
||||
countStmt.bind([term]);
|
||||
countStmt.bind([likeTerm]);
|
||||
if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0;
|
||||
countStmt.free();
|
||||
|
||||
if (total === 0) {
|
||||
table.clear();
|
||||
$status.textContent = 'No artists found';
|
||||
listState.showEmpty('No artists found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (offset >= total) state.page = Math.max(1, Math.ceil(total / state.pageSize));
|
||||
|
||||
const stmt = db.prepare(rowsSql);
|
||||
stmt.bind([term, state.pageSize, (state.page - 1) * state.pageSize]);
|
||||
while (stmt.step()) rows.push(stmt.getAsObject());
|
||||
stmt.free();
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue