From 2ee403ddfb7fe860de3621e9eebca7a285d7f500 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 16 Sep 2025 21:48:04 -0500 Subject: [PATCH 1/4] UX: enable search input; add detailed UX elements TODO roadmap to AGENTS.md --- AGENTS.md | 89 ++++++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 3 +- 2 files changed, 91 insertions(+), 1 deletion(-) diff --git a/AGENTS.md b/AGENTS.md index 1b8476e..fdf338b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,3 +197,92 @@ SELECT year, COUNT(*) FROM tracks GROUP BY year ORDER BY year; - When editing `index.html`/`script.js`, include inline comments explaining assumptions. - Verify changes against the schema above. +--- + +## TODOs / UX Elements Roadmap + +Overview +- Views are "base" (replace main content) or "overlay" (stacked dialog) via `UX.replace(...)` and `UX.openOverlay(...)` in `script.js`. +- Each view pairs an HTML template in `index.html` (e.g., `tpl-...`) with a creator in `script.js` (e.g., `create...View(db)`), returning `{ kind, el, onShow?, destroy? }`. +- Use Bulma form/table/pagination patterns. Keep DOM small; paginate and debounce queries. + +Shared Tasks +- [ ] Add small table/list renderer util in `script.js` to build rows safely (uses `escapeHtml`). +- [ ] Add shared pagination component (Prev/Next, page size select). Propagate `LIMIT/OFFSET`. +- [ ] Add common keyboard handlers: Enter to open selection; Esc to close overlays (already wired globally). +- [ ] Add loading/empty-state helpers for lists. + +Primary Navigation (Hub) +- [ ] `tpl-nav` (base): Landing hub to choose "Search", "Browse Artists", "Browse Albums", "Browse Years", "Browse Genres", "Stats". +- [ ] `createNavView(db)`: Buttons/cards trigger `UX.replace(...)` to corresponding base views. +- [ ] Accessibility: initial focus on first action; arrow-key navigation across items; visible focus states. + +Search (Existing) +- [x] `tpl-search` (base): Input is focusable; shows results area. +- [ ] Implement query execution with FTS join; debounce 250 ms; paginate results. +- SQL: `SELECT t.id, a.name AS artist, t.title, IFNULL(al.title,'') AS album, t.year, t.genre FROM fts_tracks f JOIN tracks t ON t.id=f.rowid JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE f MATCH ? ORDER BY rank LIMIT ? OFFSET ?`. +- [ ] Column sorts (toggle rank vs artist,title,year). +- [ ] Row activation opens Track overlay. + +Browse Artists +- [ ] `tpl-browse-artists` (base): Alphabetical list, A–Z quick jump, mini filter box; paginated. +- [ ] `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. + +Browse Albums +- [ ] `tpl-browse-albums` (base): List or grid with title, artist badge, year; paginated and sortable by artist/year/title. +- [ ] `createBrowseAlbumsView(db)`: Clicking item opens Album overlay. +- SQL: `SELECT al.id, al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id=al.artist_id ORDER BY a.name, al.year, al.title LIMIT ? OFFSET ?`. + +Browse Years +- [ ] `tpl-browse-years` (base): Year histogram (counts) with list of tracks/albums when a year is selected; paginated. +- [ ] `createBrowseYearsView(db)`: Selecting a year shows tracks; rows open Album/Track overlays. +- SQL (counts): `SELECT year, COUNT(*) AS cnt FROM tracks WHERE year IS NOT NULL GROUP BY year ORDER BY year`. +- SQL (tracks by year): `SELECT t.id, t.title, a.name AS artist, IFNULL(al.title,'') AS album, t.genre FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.year=? ORDER BY a.name, t.title LIMIT ? OFFSET ?`. + +Browse Genres +- [ ] `tpl-browse-genres` (base): Genre chips with counts → selecting shows paginated tracks. +- [ ] `createBrowseGenresView(db)`: Genre list with counts; selecting lists tracks; rows open Track overlay. +- SQL (counts): `SELECT genre, COUNT(*) AS cnt FROM tracks WHERE genre IS NOT NULL AND genre!='' GROUP BY genre ORDER BY cnt DESC, genre`. +- SQL (tracks by genre): `SELECT t.id, t.title, a.name AS artist, IFNULL(al.title,'') AS album, t.year FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.genre=? ORDER BY a.name, t.title LIMIT ? OFFSET ?`. + +Stats +- [ ] `tpl-stats` (base): Lightweight metrics (totals, top artists, year distribution) linking into browse views. +- [ ] `createStatsView(db)`: Render summary cards; links navigate via `UX.replace(...)` with preselected filters. +- SQL (examples): totals from `COUNT(*)` on artists/albums/tracks; top artists via `SELECT a.name, COUNT(*) cnt FROM tracks t JOIN artists a ON a.id=t.artist_id GROUP BY a.id ORDER BY cnt DESC LIMIT 20`. + +Artist Overlay +- [ ] `tpl-artist` (overlay): Header: name + counts; tabs: Albums | Top Tracks. +- [ ] `createArtistOverlay(db, artistId)`: Load artist name, counts, then tab content. +- SQL (albums): `SELECT id, title, year FROM albums WHERE artist_id=? ORDER BY year, title`. +- SQL (top tracks): `SELECT id, title, year, genre FROM tracks WHERE artist_id=? ORDER BY year, title LIMIT 100`. +- [ ] Actions: clicking album opens Album overlay; clicking track opens Track overlay. + +Album Overlay +- [ ] `tpl-album` (overlay): Header with album title, artist, year; tracklist table with `track_no`, `title`, `duration_sec`, `bitrate_kbps`. +- [ ] `createAlbumOverlay(db, albumId)`: Load album+artist header; then tracklist. +- SQL (header): `SELECT al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id=al.artist_id WHERE al.id=?`. +- SQL (tracks): `SELECT id, track_no, title, duration_sec, bitrate_kbps FROM tracks WHERE album_id=? ORDER BY track_no, title`. +- [ ] Row activation opens Track overlay. + +Track Overlay +- [ ] `tpl-track` (overlay): Show title, artist, album, year, genre, duration, bitrate, samplerate, channels, filesize, sha1 (if present), and `relpath` with a Copy button. +- [ ] `createTrackOverlay(db, trackId)`: Load detail from join; add Copy action for `relpath`. +- SQL: `SELECT t.*, a.name AS artist, al.title AS album FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.id=?`. + +Filters (Optional) +- [ ] `tpl-filters` (overlay): Advanced filters (year range, min bitrate, genre multi-select) applied to current base view. +- [ ] `createFiltersOverlay(db, onApply)`: Applies constraints and refreshes the invoking view. + +Help/Meta Overlays +- [ ] `tpl-keyboard-shortcuts` (overlay): " / focus search", "Esc close overlay", "j/k navigate" if list navigation is added. +- [ ] `tpl-about` (overlay): About/help, privacy note. +- [ ] `tpl-error` (overlay): Friendly error with retry; used by views on failure. + +Implementation Notes +- Use template IDs in `index.html` and instantiate via existing `instantiateTemplate` helper. +- Overlays must add `ux-view--overlay` class (done by UX manager) and include a close button that calls `UX.closeTop()`. +- Keep queries read-only; always `LIMIT ? OFFSET ?` for lists; avoid `SELECT *` except for single-row detail. +- Respect accessibility: label–input associations, `aria-live` only for async status, focus returned to opener on overlay close. +- Performance: debounce search 200–300 ms; cache prepared statements if beneficial; do not pre-render large lists. diff --git a/index.html b/index.html index 4ad75a1..b6bb1ad 100644 --- a/index.html +++ b/index.html @@ -44,7 +44,8 @@
- + +

Powered by in-browser SQLite FTS; no network queries.

From ad543d67b790090f1d1dbfa48a0b8ab942f88fcb Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 16 Sep 2025 22:17:29 -0500 Subject: [PATCH 2/4] Add reusable table and pagination helpers --- AGENTS.md | 6 +- script.js | 431 ++++++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 421 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index fdf338b..0fe911e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -207,8 +207,8 @@ Overview - Use Bulma form/table/pagination patterns. Keep DOM small; paginate and debounce queries. Shared Tasks -- [ ] Add small table/list renderer util in `script.js` to build rows safely (uses `escapeHtml`). -- [ ] Add shared pagination component (Prev/Next, page size select). Propagate `LIMIT/OFFSET`. +- [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. @@ -219,7 +219,7 @@ Primary Navigation (Hub) Search (Existing) - [x] `tpl-search` (base): Input is focusable; shows results area. -- [ ] Implement query execution with FTS join; debounce 250 ms; paginate results. +- [x] Implement query execution with FTS join; debounce 250 ms; paginate results. Wired into the new table + pagination helpers. - SQL: `SELECT t.id, a.name AS artist, t.title, IFNULL(al.title,'') AS album, t.year, t.genre FROM fts_tracks f JOIN tracks t ON t.id=f.rowid JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE f MATCH ? ORDER BY rank LIMIT ? OFFSET ?`. - [ ] Column sorts (toggle rank vs artist,title,year). - [ ] Row activation opens Track overlay. diff --git a/script.js b/script.js index e8403e2..d5cf6c0 100644 --- a/script.js +++ b/script.js @@ -226,6 +226,281 @@ return root; } + function createTableRenderer({ columns, emptyMessage = 'No results', getRowId }) { + if (!Array.isArray(columns) || columns.length === 0) throw new Error('columns required'); + let currentEmpty = emptyMessage; + const table = document.createElement('table'); + table.className = 'table is-fullwidth is-striped is-hoverable'; + const thead = document.createElement('thead'); + const headRow = document.createElement('tr'); + columns.forEach((column) => { + const th = document.createElement('th'); + th.scope = 'col'; + th.textContent = column.header || ''; + if (column.headerTitle) th.title = column.headerTitle; + headRow.appendChild(th); + }); + thead.appendChild(headRow); + const tbody = document.createElement('tbody'); + table.appendChild(thead); + table.appendChild(tbody); + + function renderEmpty(message) { + tbody.innerHTML = ''; + const tr = document.createElement('tr'); + const td = document.createElement('td'); + td.colSpan = columns.length; + td.className = 'has-text-centered has-text-grey'; + td.textContent = message; + tr.appendChild(td); + tbody.appendChild(tr); + tbody.dataset.state = 'empty'; + } + + function renderRows(rows) { + tbody.innerHTML = ''; + if (!rows || rows.length === 0) { + renderEmpty(currentEmpty); + return; + } + delete tbody.dataset.state; + rows.forEach((row) => { + const tr = document.createElement('tr'); + if (typeof getRowId === 'function') { + const rowId = getRowId(row); + if (rowId !== undefined && rowId !== null) tr.dataset.rowId = String(rowId); + } + columns.forEach((column) => { + const td = document.createElement('td'); + if (column.className) td.className = column.className; + let value; + if (typeof column.render === 'function') value = column.render(row); + else if ('key' in column) value = row[column.key]; + else value = ''; + if (value instanceof Node) td.appendChild(value); + else { + const text = value === undefined || value === null ? '' : String(value); + td.innerHTML = escapeHtml(text); + } + tr.appendChild(td); + }); + tbody.appendChild(tr); + }); + } + + renderEmpty(currentEmpty); + + return { + el: table, + setRows(rows) { renderRows(rows); }, + setEmptyMessage(message) { + currentEmpty = message; + if (tbody.dataset.state === 'empty') renderEmpty(currentEmpty); + }, + clear() { + renderEmpty(currentEmpty); + }, + }; + } + + let paginationIdCounter = 0; + function createPagination({ + pageSizes = [10, 25, 50, 100], + initialPageSize, + onChange, + } = {}) { + if (!pageSizes.length) pageSizes = [25]; + const defaultSize = initialPageSize && pageSizes.includes(initialPageSize) ? initialPageSize : pageSizes[0]; + const selectId = `pagination-select-${++paginationIdCounter}`; + const state = { + page: 1, + pageSize: defaultSize, + total: 0, + resultsCount: 0, + disabled: true, + }; + + const wrapper = document.createElement('div'); + wrapper.className = 'mt-4'; + wrapper.setAttribute('hidden', ''); + + const level = document.createElement('div'); + level.className = 'level is-mobile'; + wrapper.appendChild(level); + + const levelLeft = document.createElement('div'); + levelLeft.className = 'level-left'; + level.appendChild(levelLeft); + + const levelLeftItem = document.createElement('div'); + levelLeftItem.className = 'level-item'; + levelLeft.appendChild(levelLeftItem); + + const nav = document.createElement('nav'); + nav.className = 'pagination is-small'; + nav.setAttribute('role', 'navigation'); + nav.setAttribute('aria-label', 'Pagination'); + + const prevBtn = document.createElement('button'); + prevBtn.type = 'button'; + prevBtn.className = 'pagination-previous'; + prevBtn.textContent = 'Previous'; + nav.appendChild(prevBtn); + + const nextBtn = document.createElement('button'); + nextBtn.type = 'button'; + nextBtn.className = 'pagination-next'; + nextBtn.textContent = 'Next'; + nav.appendChild(nextBtn); + + levelLeftItem.appendChild(nav); + + const levelRight = document.createElement('div'); + levelRight.className = 'level-right'; + level.appendChild(levelRight); + + const levelRightItem = document.createElement('div'); + levelRightItem.className = 'level-item'; + levelRight.appendChild(levelRightItem); + + const sizeField = document.createElement('div'); + sizeField.className = 'field is-grouped is-align-items-center mb-0'; + + const sizeLabel = document.createElement('label'); + sizeLabel.className = 'label is-size-7 mb-0 mr-2'; + sizeLabel.setAttribute('for', selectId); + sizeLabel.textContent = 'Rows per page'; + sizeField.appendChild(sizeLabel); + + const sizeControl = document.createElement('div'); + sizeControl.className = 'control'; + const selectWrapper = document.createElement('div'); + selectWrapper.className = 'select is-small'; + const sizeSelect = document.createElement('select'); + sizeSelect.id = selectId; + pageSizes.forEach((size) => { + const opt = document.createElement('option'); + opt.value = String(size); + opt.textContent = String(size); + sizeSelect.appendChild(opt); + }); + sizeSelect.value = String(state.pageSize); + selectWrapper.appendChild(sizeSelect); + sizeControl.appendChild(selectWrapper); + sizeField.appendChild(sizeControl); + levelRightItem.appendChild(sizeField); + + const meta = document.createElement('p'); + meta.className = 'is-size-7 has-text-grey mt-2'; + meta.textContent = ''; + wrapper.appendChild(meta); + + function emitChange() { + if (typeof onChange === 'function') { + onChange({ page: state.page, pageSize: state.pageSize }); + } + } + + function updateControls() { + if (state.disabled) { + wrapper.setAttribute('hidden', ''); + } else { + wrapper.removeAttribute('hidden'); + } + const atFirst = state.page <= 1; + const hasTotal = Number.isFinite(state.total) && state.total >= 0; + const hasResults = state.resultsCount > 0; + const maxKnown = hasTotal ? state.page * state.pageSize >= state.total : state.resultsCount < state.pageSize; + prevBtn.disabled = state.disabled || atFirst; + nextBtn.disabled = state.disabled || maxKnown; + sizeSelect.disabled = state.disabled; + + if (state.disabled) { + meta.textContent = ''; + return; + } + + if (hasTotal) { + if (state.total === 0) { + meta.textContent = 'No results'; + return; + } + const start = (state.page - 1) * state.pageSize + 1; + const end = Math.min(state.total, start + state.resultsCount - 1); + meta.textContent = `${start.toLocaleString()}–${end.toLocaleString()} of ${state.total.toLocaleString()} results`; + } else if (hasResults) { + const start = (state.page - 1) * state.pageSize + 1; + const end = start + state.resultsCount - 1; + meta.textContent = `${start.toLocaleString()}–${end.toLocaleString()} results`; + } else { + meta.textContent = 'No results'; + } + } + + prevBtn.addEventListener('click', () => { + if (state.disabled || state.page <= 1) return; + state.page -= 1; + updateControls(); + emitChange(); + }); + + nextBtn.addEventListener('click', () => { + if (state.disabled) return; + const hasTotal = Number.isFinite(state.total) && state.total >= 0; + if (hasTotal && state.page * state.pageSize >= state.total) return; + if (!hasTotal && state.resultsCount < state.pageSize) return; + state.page += 1; + updateControls(); + emitChange(); + }); + + sizeSelect.addEventListener('change', () => { + const nextSize = parseInt(sizeSelect.value, 10); + if (!Number.isFinite(nextSize) || nextSize <= 0) return; + if (nextSize === state.pageSize) return; + state.pageSize = nextSize; + state.page = 1; + updateControls(); + emitChange(); + }); + + updateControls(); + + return { + el: wrapper, + get page() { return state.page; }, + get pageSize() { return state.pageSize; }, + setDisabled(disabled) { + state.disabled = Boolean(disabled); + updateControls(); + }, + setState({ page, pageSize, total, resultsCount }) { + if (Number.isFinite(page) && page >= 1) state.page = page; + if (Number.isFinite(pageSize) && pageSize > 0) { + state.pageSize = pageSize; + sizeSelect.value = String(state.pageSize); + } + if (Number.isFinite(total) && total >= 0) state.total = total; + else state.total = NaN; + if (Number.isFinite(resultsCount) && resultsCount >= 0) state.resultsCount = resultsCount; + else state.resultsCount = 0; + state.disabled = false; + updateControls(); + }, + }; + } + + function debounce(fn, wait) { + let timer = null; + return function debounced(...args) { + if (timer) clearTimeout(timer); + timer = setTimeout(() => { + timer = null; + fn.apply(this, args); + }, wait); + }; + } + function createLoaderView() { const el = instantiateTemplate('tpl-loader'); const $step = el.querySelector('[data-ref="step"]'); @@ -258,33 +533,164 @@ const $q = el.querySelector('[data-ref="q"]'); const $results = el.querySelector('[data-ref="results"]'); - function renderStub(qVal) { - if (!qVal) { - $results.innerHTML = '

Type to search…

'; - } else { - $results.innerHTML = `

Search stub — ready for query: ${escapeHtml(qVal)}

`; - } + const $status = document.createElement('p'); + $status.className = 'has-text-grey'; + $results.innerHTML = ''; + $results.appendChild($status); + + const columns = [ + { header: 'Artist', key: 'artist' }, + { header: 'Title', key: 'title' }, + { header: 'Album', key: 'album' }, + { header: 'Year', key: 'year', className: 'has-text-right' }, + { header: 'Genre', key: 'genre' }, + ]; + const table = createTableRenderer({ + columns, + emptyMessage: 'No matches found', + getRowId: (row) => row.id, + }); + table.el.hidden = true; + $results.appendChild(table.el); + + const pagination = createPagination({ + onChange: ({ page, pageSize }) => { + state.page = page; + state.pageSize = pageSize; + runSearch(); + }, + }); + $results.appendChild(pagination.el); + + const state = { + query: '', + page: 1, + pageSize: pagination.pageSize, + sort: 'rank', + }; + + const searchSql = { + rank: ` + SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre + FROM fts_tracks f + JOIN tracks t ON t.id = f.rowid + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE f MATCH ? + ORDER BY rank + LIMIT ? OFFSET ? + `, + alpha: ` + SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre + FROM fts_tracks f + JOIN tracks t ON t.id = f.rowid + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE f MATCH ? + ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE, COALESCE(t.year, 0) + LIMIT ? OFFSET ? + `, + }; + const countSql = ` + SELECT COUNT(*) AS count + FROM fts_tracks f + JOIN tracks t ON t.id = f.rowid + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE f MATCH ? + `; + + function setStatus(text) { + $status.textContent = text; + $status.hidden = false; + table.el.hidden = true; + pagination.setDisabled(true); } - function enable() { + function enableSearch() { $q.disabled = false; $q.removeAttribute('aria-disabled'); $q.focus(); } + function runSearchImmediate() { + const term = state.query.trim(); + if (!term) { + table.clear(); + setStatus('Type to search…'); + return; + } + + $status.textContent = 'Searching…'; + $status.hidden = false; + table.el.hidden = true; + pagination.setDisabled(true); + + const offset = (state.page - 1) * state.pageSize; + let total = 0; + let rows = []; + try { + const countStmt = db.prepare(countSql); + countStmt.bind([term]); + if (countStmt.step()) { + const row = countStmt.getAsObject(); + total = Number(row.count) || 0; + } + countStmt.free(); + + if (total === 0) { + table.clear(); + $status.textContent = 'No matches found'; + return; + } + + if (offset >= total) { + state.page = Math.max(1, Math.ceil(total / state.pageSize)); + } + + const searchStmt = db.prepare(searchSql[state.sort] || searchSql.rank); + searchStmt.bind([term, state.pageSize, (state.page - 1) * state.pageSize]); + const nextRows = []; + while (searchStmt.step()) { + nextRows.push(searchStmt.getAsObject()); + } + rows = nextRows; + searchStmt.free(); + } catch (err) { + console.error(err); + $status.textContent = 'Search failed. Check console for details.'; + return; + } + + table.setRows(rows); + $status.hidden = true; + table.el.hidden = false; + pagination.setState({ + page: state.page, + pageSize: state.pageSize, + total, + resultsCount: rows.length, + }); + } + + const runSearch = debounce(runSearchImmediate, 250); + $form.addEventListener('submit', (e) => e.preventDefault()); $q.addEventListener('input', () => { - const q = $q.value.trim(); - renderStub(q); - // Future: db.prepare / db.exec queries + state.query = $q.value; + state.page = 1; + runSearch(); }); - renderStub(''); + setStatus('Type to search…'); return { kind: 'base', el, - onShow() { enable(); }, + onShow() { + enableSearch(); + if (state.query.trim()) runSearchImmediate(); + }, destroy() {}, }; } @@ -335,4 +741,3 @@ .replace(/'/g, '''); } })(); - From ab7bc36714c2c4457ef6def98e8dca1b8b753ca3 Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 16 Sep 2025 22:35:35 -0500 Subject: [PATCH 3/4] Add navigation, browse views, overlays, and update roadmap --- AGENTS.md | 46 +- index.html | 283 ++++++++++ script.js | 1554 +++++++++++++++++++++++++++++++++++++++++++++++++++- site.css | 14 + 4 files changed, 1862 insertions(+), 35 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 0fe911e..cda60d8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -213,62 +213,62 @@ Shared Tasks - [ ] Add loading/empty-state helpers for lists. Primary Navigation (Hub) -- [ ] `tpl-nav` (base): Landing hub to choose "Search", "Browse Artists", "Browse Albums", "Browse Years", "Browse Genres", "Stats". -- [ ] `createNavView(db)`: Buttons/cards trigger `UX.replace(...)` to corresponding base views. -- [ ] Accessibility: initial focus on first action; arrow-key navigation across items; visible focus states. +- [x] `tpl-nav` (base): Landing hub to choose "Search", "Browse Artists", "Browse Albums", "Browse Years", "Browse Genres", "Stats". +- [x] `createNavView(db)`: Buttons/cards trigger `UX.replace(...)` to corresponding base views. +- [x] Accessibility: initial focus on first action; arrow-key navigation across items; visible focus states. Search (Existing) - [x] `tpl-search` (base): Input is focusable; shows results area. - [x] Implement query execution with FTS join; debounce 250 ms; paginate results. Wired into the new table + pagination helpers. - SQL: `SELECT t.id, a.name AS artist, t.title, IFNULL(al.title,'') AS album, t.year, t.genre FROM fts_tracks f JOIN tracks t ON t.id=f.rowid JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE f MATCH ? ORDER BY rank LIMIT ? OFFSET ?`. -- [ ] Column sorts (toggle rank vs artist,title,year). -- [ ] Row activation opens Track overlay. +- [x] Column sorts (toggle rank vs artist,title,year). +- [x] Row activation opens Track overlay. Browse Artists -- [ ] `tpl-browse-artists` (base): Alphabetical list, A–Z quick jump, mini filter box; paginated. -- [ ] `createBrowseArtistsView(db)`: Loads pages; clicking row opens Artist overlay. +- [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. Browse Albums -- [ ] `tpl-browse-albums` (base): List or grid with title, artist badge, year; paginated and sortable by artist/year/title. -- [ ] `createBrowseAlbumsView(db)`: Clicking item opens Album overlay. +- [x] `tpl-browse-albums` (base): List or grid with title, artist badge, year; paginated and sortable by artist/year/title. +- [x] `createBrowseAlbumsView(db)`: Clicking item opens Album overlay. - SQL: `SELECT al.id, al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id=al.artist_id ORDER BY a.name, al.year, al.title LIMIT ? OFFSET ?`. Browse Years -- [ ] `tpl-browse-years` (base): Year histogram (counts) with list of tracks/albums when a year is selected; paginated. -- [ ] `createBrowseYearsView(db)`: Selecting a year shows tracks; rows open Album/Track overlays. +- [x] `tpl-browse-years` (base): Year histogram (counts) with list of tracks/albums when a year is selected; paginated. +- [x] `createBrowseYearsView(db)`: Selecting a year shows tracks; rows open Album/Track overlays. - SQL (counts): `SELECT year, COUNT(*) AS cnt FROM tracks WHERE year IS NOT NULL GROUP BY year ORDER BY year`. - SQL (tracks by year): `SELECT t.id, t.title, a.name AS artist, IFNULL(al.title,'') AS album, t.genre FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.year=? ORDER BY a.name, t.title LIMIT ? OFFSET ?`. Browse Genres -- [ ] `tpl-browse-genres` (base): Genre chips with counts → selecting shows paginated tracks. -- [ ] `createBrowseGenresView(db)`: Genre list with counts; selecting lists tracks; rows open Track overlay. +- [x] `tpl-browse-genres` (base): Genre chips with counts → selecting shows paginated tracks. +- [x] `createBrowseGenresView(db)`: Genre list with counts; selecting lists tracks; rows open Track overlay. - SQL (counts): `SELECT genre, COUNT(*) AS cnt FROM tracks WHERE genre IS NOT NULL AND genre!='' GROUP BY genre ORDER BY cnt DESC, genre`. - SQL (tracks by genre): `SELECT t.id, t.title, a.name AS artist, IFNULL(al.title,'') AS album, t.year FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.genre=? ORDER BY a.name, t.title LIMIT ? OFFSET ?`. Stats -- [ ] `tpl-stats` (base): Lightweight metrics (totals, top artists, year distribution) linking into browse views. -- [ ] `createStatsView(db)`: Render summary cards; links navigate via `UX.replace(...)` with preselected filters. +- [x] `tpl-stats` (base): Lightweight metrics (totals, top artists, year distribution) linking into browse views. +- [x] `createStatsView(db)`: Render summary cards; links navigate via `UX.replace(...)` with preselected filters. - SQL (examples): totals from `COUNT(*)` on artists/albums/tracks; top artists via `SELECT a.name, COUNT(*) cnt FROM tracks t JOIN artists a ON a.id=t.artist_id GROUP BY a.id ORDER BY cnt DESC LIMIT 20`. Artist Overlay -- [ ] `tpl-artist` (overlay): Header: name + counts; tabs: Albums | Top Tracks. -- [ ] `createArtistOverlay(db, artistId)`: Load artist name, counts, then tab content. +- [x] `tpl-artist` (overlay): Header: name + counts; tabs: Albums | Top Tracks. +- [x] `createArtistOverlay(db, artistId)`: Load artist name, counts, then tab content. - SQL (albums): `SELECT id, title, year FROM albums WHERE artist_id=? ORDER BY year, title`. - SQL (top tracks): `SELECT id, title, year, genre FROM tracks WHERE artist_id=? ORDER BY year, title LIMIT 100`. -- [ ] Actions: clicking album opens Album overlay; clicking track opens Track overlay. +- [x] Actions: clicking album opens Album overlay; clicking track opens Track overlay. Album Overlay -- [ ] `tpl-album` (overlay): Header with album title, artist, year; tracklist table with `track_no`, `title`, `duration_sec`, `bitrate_kbps`. -- [ ] `createAlbumOverlay(db, albumId)`: Load album+artist header; then tracklist. +- [x] `tpl-album` (overlay): Header with album title, artist, year; tracklist table with `track_no`, `title`, `duration_sec`, `bitrate_kbps`. +- [x] `createAlbumOverlay(db, albumId)`: Load album+artist header; then tracklist. - SQL (header): `SELECT al.title, al.year, a.name AS artist FROM albums al JOIN artists a ON a.id=al.artist_id WHERE al.id=?`. - SQL (tracks): `SELECT id, track_no, title, duration_sec, bitrate_kbps FROM tracks WHERE album_id=? ORDER BY track_no, title`. -- [ ] Row activation opens Track overlay. +- [x] Row activation opens Track overlay. Track Overlay -- [ ] `tpl-track` (overlay): Show title, artist, album, year, genre, duration, bitrate, samplerate, channels, filesize, sha1 (if present), and `relpath` with a Copy button. -- [ ] `createTrackOverlay(db, trackId)`: Load detail from join; add Copy action for `relpath`. +- [x] `tpl-track` (overlay): Show title, artist, album, year, genre, duration, bitrate, samplerate, channels, filesize, sha1 (if present), and `relpath` with a Copy button. +- [x] `createTrackOverlay(db, trackId)`: Load detail from join; add Copy action for `relpath`. - SQL: `SELECT t.*, a.name AS artist, al.title AS album FROM tracks t JOIN artists a ON a.id=t.artist_id LEFT JOIN albums al ON al.id=t.album_id WHERE t.id=?`. Filters (Optional) diff --git a/index.html b/index.html index b6bb1ad..9be3e96 100644 --- a/index.html +++ b/index.html @@ -57,6 +57,289 @@ + + + + + + + + + + + + + + + + + + diff --git a/script.js b/script.js index d5cf6c0..5b87675 100644 --- a/script.js +++ b/script.js @@ -191,6 +191,30 @@ return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`; } + function formatNumber(value) { + if (!Number.isFinite(value)) return '—'; + return Number(value).toLocaleString(); + } + + function formatDuration(seconds) { + if (!Number.isFinite(seconds) || seconds < 0) return '—'; + const mins = Math.floor(seconds / 60); + const secs = Math.floor(seconds % 60) + .toString() + .padStart(2, '0'); + return `${mins}:${secs}`; + } + + function formatBitrate(kbps) { + if (!Number.isFinite(kbps) || kbps <= 0) return '—'; + return `${kbps.toLocaleString()} kbps`; + } + + function formatSamplerate(hz) { + if (!Number.isFinite(hz) || hz <= 0) return '—'; + return `${hz.toLocaleString()} Hz`; + } + async function unzipSqlite(zipBytes) { loader.setStep('Unpacking database…'); loader.setDetail('Decompressing ZIP'); @@ -226,7 +250,13 @@ return root; } - function createTableRenderer({ columns, emptyMessage = 'No results', getRowId }) { + function createTableRenderer({ + columns, + emptyMessage = 'No results', + getRowId, + interactive = false, + onRowRender, + }) { if (!Array.isArray(columns) || columns.length === 0) throw new Error('columns required'); let currentEmpty = emptyMessage; const table = document.createElement('table'); @@ -270,6 +300,10 @@ const rowId = getRowId(row); if (rowId !== undefined && rowId !== null) tr.dataset.rowId = String(rowId); } + if (interactive) { + tr.tabIndex = 0; + tr.classList.add('is-selectable-row'); + } columns.forEach((column) => { const td = document.createElement('td'); if (column.className) td.className = column.className; @@ -284,22 +318,53 @@ } tr.appendChild(td); }); + if (typeof onRowRender === 'function') onRowRender(tr, row); tbody.appendChild(tr); }); - } + } - renderEmpty(currentEmpty); + renderEmpty(currentEmpty); - return { + return { el: table, setRows(rows) { renderRows(rows); }, setEmptyMessage(message) { currentEmpty = message; if (tbody.dataset.state === 'empty') renderEmpty(currentEmpty); - }, - clear() { - renderEmpty(currentEmpty); - }, + }, + clear() { + renderEmpty(currentEmpty); + }, + }; + } + + function bindRowActivation(tableEl, handler) { + if (!tableEl || typeof handler !== 'function') return () => {}; + function findRow(target) { + let el = target; + while (el && el !== tableEl) { + if (el.dataset && el.dataset.rowId) return el; + el = el.parentElement; + } + return null; + } + const onClick = (event) => { + const row = findRow(event.target); + if (!row) return; + handler(row.dataset.rowId, event); + }; + const onKeyDown = (event) => { + if (event.key !== 'Enter' && event.key !== ' ') return; + const row = findRow(event.target); + if (!row) return; + event.preventDefault(); + handler(row.dataset.rowId, event); + }; + tableEl.addEventListener('click', onClick); + tableEl.addEventListener('keydown', onKeyDown); + return () => { + tableEl.removeEventListener('click', onClick); + tableEl.removeEventListener('keydown', onKeyDown); }; } @@ -527,12 +592,41 @@ }; } - function createSearchView(db) { + function createSearchView(db, { initialQuery = '' } = {}) { const el = instantiateTemplate('tpl-search'); const $form = el.querySelector('[data-ref="form"]'); const $q = el.querySelector('[data-ref="q"]'); const $results = el.querySelector('[data-ref="results"]'); + // Back + sort toolbar keeps controls grouped for accessibility. + const $toolbar = document.createElement('div'); + $toolbar.className = 'level mb-4 is-mobile'; + const $toolbarLeft = document.createElement('div'); + $toolbarLeft.className = 'level-left'; + const $toolbarLeftItem = document.createElement('div'); + $toolbarLeftItem.className = 'level-item'; + const $backBtn = document.createElement('button'); + $backBtn.type = 'button'; + $backBtn.className = 'button is-small is-text'; + $backBtn.textContent = 'Back to menu'; + $toolbarLeftItem.appendChild($backBtn); + $toolbarLeft.appendChild($toolbarLeftItem); + const $toolbarRight = document.createElement('div'); + $toolbarRight.className = 'level-right'; + const $toolbarRightItem = document.createElement('div'); + $toolbarRightItem.className = 'level-item'; + const $sortLabel = document.createElement('span'); + $sortLabel.className = 'is-size-7 has-text-grey mr-2'; + $sortLabel.textContent = 'Sort'; + const $sortButtons = document.createElement('div'); + $sortButtons.className = 'buttons has-addons is-small'; + $toolbarRightItem.appendChild($sortLabel); + $toolbarRightItem.appendChild($sortButtons); + $toolbarRight.appendChild($toolbarRightItem); + $toolbar.appendChild($toolbarLeft); + $toolbar.appendChild($toolbarRight); + $form.insertAdjacentElement('afterend', $toolbar); + const $status = document.createElement('p'); $status.className = 'has-text-grey'; $results.innerHTML = ''; @@ -549,6 +643,7 @@ columns, emptyMessage: 'No matches found', getRowId: (row) => row.id, + interactive: true, }); table.el.hidden = true; $results.appendChild(table.el); @@ -563,12 +658,40 @@ $results.appendChild(pagination.el); const state = { - query: '', + query: initialQuery, page: 1, pageSize: pagination.pageSize, sort: 'rank', }; + const sortOptions = [ + { key: 'rank', label: 'Relevance' }, + { key: 'alpha', label: 'A–Z' }, + ]; + + sortOptions.forEach((opt) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button is-small'; + btn.dataset.sort = opt.key; + btn.textContent = opt.label; + btn.addEventListener('click', () => { + if (state.sort === opt.key) return; + state.sort = opt.key; + state.page = 1; + updateSortButtons(); + runSearchImmediate(); + }); + $sortButtons.appendChild(btn); + }); + + function updateSortButtons() { + [...$sortButtons.querySelectorAll('button')].forEach((btn) => { + if (btn.dataset.sort === state.sort) btn.classList.add('is-link'); + else btn.classList.remove('is-link'); + }); + } + const searchSql = { rank: ` SELECT t.id, a.name AS artist, t.title, IFNULL(al.title, '') AS album, t.year, t.genre @@ -610,6 +733,9 @@ function enableSearch() { $q.disabled = false; $q.removeAttribute('aria-disabled'); + if (state.query) { + $q.value = state.query; + } $q.focus(); } @@ -681,7 +807,16 @@ state.page = 1; runSearch(); }); + $backBtn.addEventListener('click', () => navigateTo('nav')); + const unbindRows = bindRowActivation(table.el, (rowId) => { + if (!rowId) return; + const trackId = Number(rowId); + if (!Number.isFinite(trackId)) return; + UX.openOverlay(createTrackOverlay(db, trackId)); + }); + + updateSortButtons(); setStatus('Type to search…'); return { @@ -691,10 +826,1404 @@ enableSearch(); if (state.query.trim()) runSearchImmediate(); }, + destroy() { + unbindRows(); + }, + }; + } + + function createNavView(db) { + const el = instantiateTemplate('tpl-nav'); + const $backTargets = Array.from(el.querySelectorAll('button[data-action]')); + const actions = { + search: () => navigateTo('search'), + artists: () => navigateTo('browseArtists'), + albums: () => navigateTo('browseAlbums'), + years: () => navigateTo('browseYears'), + genres: () => navigateTo('browseGenres'), + stats: () => navigateTo('stats'), + }; + + $backTargets.forEach((btn) => { + const action = btn.dataset.action; + if (actions[action]) { + btn.addEventListener('click', () => actions[action]()); + } + btn.addEventListener('keydown', (event) => { + let delta = 0; + if (event.key === 'ArrowRight' || event.key === 'ArrowDown') delta = 1; + else if (event.key === 'ArrowLeft' || event.key === 'ArrowUp') delta = -1; + else return; + event.preventDefault(); + const index = $backTargets.indexOf(event.currentTarget); + if (index === -1) return; + const nextIndex = (index + delta + $backTargets.length) % $backTargets.length; + $backTargets[nextIndex].focus(); + }); + }); + + return { + kind: 'base', + el, + onShow() { + if ($backTargets.length) $backTargets[0].focus(); + }, destroy() {}, }; } + function createBrowseArtistsView(db, { initialPrefix = null, initialFilter = '' } = {}) { + const el = instantiateTemplate('tpl-browse-artists'); + const $filter = el.querySelector('[data-ref="filter"]'); + const $results = el.querySelector('[data-ref="results"]'); + const $back = el.querySelector('[data-action="back"]'); + const jumpButtons = Array.from(el.querySelectorAll('[data-action="jump"]')); + + const $status = document.createElement('p'); + $status.className = 'has-text-grey'; + $results.innerHTML = ''; + $results.appendChild($status); + + const table = createTableRenderer({ + columns: [{ header: 'Artist', key: 'name' }], + emptyMessage: 'No artists found', + getRowId: (row) => row.id, + interactive: true, + }); + table.el.hidden = true; + $results.appendChild(table.el); + + const pagination = createPagination({ + onChange: ({ page, pageSize }) => { + state.page = page; + state.pageSize = pageSize; + loadArtists(); + }, + }); + $results.appendChild(pagination.el); + + const state = { + prefix: initialPrefix, + filter: initialFilter, + page: 1, + pageSize: pagination.pageSize, + }; + + function escapeLike(str) { + return String(str).replace(/[\\%_]/g, (m) => `\\${m}`); + } + + function buildTerm() { + const typed = state.filter.trim(); + if (typed) return `%${escapeLike(typed)}%`; + if (state.prefix) return `${escapeLike(state.prefix)}%`; + return '%'; + } + + const countSql = 'SELECT COUNT(*) AS count FROM artists WHERE name LIKE ? ESCAPE "\\"'; + const rowsSql = ` + SELECT id, name + FROM artists + WHERE name LIKE ? ESCAPE "\\" + ORDER BY name COLLATE NOCASE + LIMIT ? OFFSET ? + `; + + function setStatus(text) { + $status.textContent = text; + $status.hidden = false; + table.el.hidden = true; + pagination.setDisabled(true); + } + + function updateJumpButtons() { + jumpButtons.forEach((btn) => { + const letter = btn.dataset.letter || 'all'; + const active = (!state.prefix && letter === 'all') || (state.prefix && letter.toLowerCase() === state.prefix.toLowerCase()); + if (active) btn.classList.add('is-link'); + else btn.classList.remove('is-link'); + }); + } + + function loadArtistsImmediate() { + const term = buildTerm(); + $status.textContent = 'Loading…'; + $status.hidden = false; + table.el.hidden = true; + pagination.setDisabled(true); + + 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(); + + if (total === 0) { + table.clear(); + $status.textContent = '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(); + } catch (err) { + console.error(err); + $status.textContent = 'Failed to load artists.'; + return; + } + + table.setRows(rows); + $status.hidden = true; + table.el.hidden = false; + pagination.setState({ + page: state.page, + pageSize: state.pageSize, + total, + resultsCount: rows.length, + }); + } + + const loadArtists = debounce(loadArtistsImmediate, 200); + + $filter.value = state.filter; + $filter.addEventListener('input', () => { + state.filter = $filter.value; + state.prefix = null; + state.page = 1; + updateJumpButtons(); + loadArtists(); + }); + + jumpButtons.forEach((btn) => { + btn.addEventListener('click', () => { + const letter = btn.dataset.letter || 'all'; + state.prefix = letter === 'all' ? null : letter; + state.filter = ''; + $filter.value = ''; + state.page = 1; + updateJumpButtons(); + loadArtistsImmediate(); + }); + }); + + $back.addEventListener('click', () => navigateTo('nav')); + + const unbindRows = bindRowActivation(table.el, (rowId) => { + const artistId = Number(rowId); + if (!Number.isFinite(artistId)) return; + UX.openOverlay(createArtistOverlay(db, artistId)); + }); + + updateJumpButtons(); + setStatus('Loading…'); + loadArtistsImmediate(); + + return { + kind: 'base', + el, + onShow() { + $filter.focus(); + }, + destroy() { + unbindRows(); + }, + }; + } + + function createBrowseAlbumsView(db, { initialSort = 'artist' } = {}) { + const el = instantiateTemplate('tpl-browse-albums'); + const $results = el.querySelector('[data-ref="results"]'); + const $back = el.querySelector('[data-action="back"]'); + const $sortSelect = el.querySelector('[data-ref="sort"]'); + + const $status = document.createElement('p'); + $status.className = 'has-text-grey'; + $results.innerHTML = ''; + $results.appendChild($status); + + const table = createTableRenderer({ + columns: [ + { header: 'Album', key: 'title' }, + { header: 'Artist', key: 'artist' }, + { header: 'Year', key: 'year', className: 'has-text-right' }, + ], + emptyMessage: 'No albums found', + getRowId: (row) => row.id, + interactive: true, + }); + table.el.hidden = true; + $results.appendChild(table.el); + + const pagination = createPagination({ + onChange: ({ page, pageSize }) => { + state.page = page; + state.pageSize = pageSize; + loadAlbums(); + }, + }); + $results.appendChild(pagination.el); + + const state = { + sort: initialSort, + page: 1, + pageSize: pagination.pageSize, + }; + + const orderMap = { + artist: 'a.name COLLATE NOCASE, COALESCE(al.year, 0), al.title COLLATE NOCASE', + year: 'COALESCE(al.year, 0), a.name COLLATE NOCASE, al.title COLLATE NOCASE', + title: 'al.title COLLATE NOCASE, a.name COLLATE NOCASE, COALESCE(al.year, 0)', + }; + + const countSql = 'SELECT COUNT(*) AS count FROM albums'; + const baseSql = ` + SELECT al.id, al.title, al.year, a.name AS artist + FROM albums al + JOIN artists a ON a.id = al.artist_id + ORDER BY %ORDER% + 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); + + let total = 0; + const rows = []; + try { + const countStmt = db.prepare(countSql); + if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; + countStmt.free(); + + if (total === 0) { + table.clear(); + $status.textContent = 'No albums found'; + return; + } + + const maxPage = Math.max(1, Math.ceil(total / state.pageSize)); + if (state.page > maxPage) state.page = maxPage; + + const order = orderMap[state.sort] || orderMap.artist; + const stmt = db.prepare(baseSql.replace('%ORDER%', order)); + stmt.bind([state.pageSize, (state.page - 1) * state.pageSize]); + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + } catch (err) { + console.error(err); + $status.textContent = 'Failed to load albums.'; + return; + } + + table.setRows(rows); + $status.hidden = true; + table.el.hidden = false; + pagination.setState({ + page: state.page, + pageSize: state.pageSize, + total, + resultsCount: rows.length, + }); + } + + const loadAlbums = debounce(loadAlbumsImmediate, 200); + + $sortSelect.value = state.sort; + $sortSelect.addEventListener('change', () => { + const next = $sortSelect.value; + if (!orderMap[next]) return; + state.sort = next; + state.page = 1; + loadAlbumsImmediate(); + }); + + $back.addEventListener('click', () => navigateTo('nav')); + + const unbindRows = bindRowActivation(table.el, (rowId) => { + const albumId = Number(rowId); + if (!Number.isFinite(albumId)) return; + UX.openOverlay(createAlbumOverlay(db, albumId)); + }); + + setStatus('Loading…'); + loadAlbumsImmediate(); + + return { + kind: 'base', + el, + onShow() { + $sortSelect.focus(); + }, + destroy() { + unbindRows(); + }, + }; + } + + function createBrowseYearsView(db, { presetYear = null } = {}) { + const el = instantiateTemplate('tpl-browse-years'); + const $back = el.querySelector('[data-action="back"]'); + const $yearsCol = el.querySelector('[data-ref="years"]'); + const $tracksCol = el.querySelector('[data-ref="tracks"]'); + + const $yearStatus = document.createElement('p'); + $yearStatus.className = 'has-text-grey'; + $yearStatus.textContent = 'Loading years…'; + $yearsCol.innerHTML = ''; + $yearsCol.appendChild($yearStatus); + + const $tracksStatus = document.createElement('p'); + $tracksStatus.className = 'has-text-grey'; + $tracksStatus.textContent = 'Select a year to view tracks.'; + $tracksCol.innerHTML = ''; + $tracksCol.appendChild($tracksStatus); + + const trackTable = createTableRenderer({ + columns: [ + { header: 'Track', key: 'title' }, + { header: 'Artist', key: 'artist' }, + { header: 'Album', key: 'album' }, + { header: 'Genre', key: 'genre' }, + ], + emptyMessage: 'No tracks for this year', + getRowId: (row) => row.id, + interactive: true, + }); + trackTable.el.hidden = true; + $tracksCol.appendChild(trackTable.el); + + const pagination = createPagination({ + onChange: ({ page, pageSize }) => { + state.page = page; + state.pageSize = pageSize; + loadTracksImmediate(); + }, + }); + $tracksCol.appendChild(pagination.el); + + const state = { + years: [], + selectedYear: presetYear, + page: 1, + pageSize: pagination.pageSize, + }; + + let yearButtons = []; + + const yearsSql = ` + SELECT year, COUNT(*) AS cnt + FROM tracks + WHERE year IS NOT NULL + GROUP BY year + ORDER BY year + `; + const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE year = ?'; + const tracksRowsSql = ` + SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.genre + FROM tracks t + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.year = ? + ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE + LIMIT ? OFFSET ? + `; + + function updateYearButtons() { + yearButtons.forEach((btn) => { + const btnYear = Number(btn.dataset.year); + if (state.selectedYear !== null && btnYear === state.selectedYear) btn.classList.add('is-link'); + else btn.classList.remove('is-link'); + }); + } + + function buildYearList() { + $yearsCol.innerHTML = ''; + if (!state.years.length) { + const empty = document.createElement('p'); + empty.className = 'has-text-grey'; + empty.textContent = 'No year data available.'; + $yearsCol.appendChild(empty); + return; + } + + const menu = document.createElement('aside'); + menu.className = 'menu'; + const list = document.createElement('ul'); + list.className = 'menu-list browse-years-list'; + menu.appendChild(list); + + yearButtons = []; + state.years.forEach((entry) => { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button is-small is-fullwidth is-light has-text-left'; + btn.dataset.year = entry.year; + btn.innerHTML = `${escapeHtml(String(entry.year))} (${formatNumber(entry.cnt)})`; + btn.addEventListener('click', () => { + state.selectedYear = Number(entry.year); + state.page = 1; + updateYearButtons(); + loadTracksImmediate(); + }); + li.appendChild(btn); + list.appendChild(li); + yearButtons.push(btn); + }); + + $yearsCol.appendChild(menu); + updateYearButtons(); + } + + function loadYears() { + try { + const stmt = db.prepare(yearsSql); + const data = []; + while (stmt.step()) data.push(stmt.getAsObject()); + stmt.free(); + state.years = data.map((row) => ({ year: Number(row.year), cnt: Number(row.cnt) })); + buildYearList(); + } catch (err) { + console.error(err); + $yearsCol.innerHTML = '

Failed to load years.

'; + } + } + + 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); + return; + } + + $tracksStatus.textContent = 'Loading tracks…'; + $tracksStatus.hidden = false; + trackTable.el.hidden = true; + pagination.setDisabled(true); + + let total = 0; + const rows = []; + try { + const countStmt = db.prepare(tracksCountSql); + countStmt.bind([state.selectedYear]); + if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; + countStmt.free(); + + if (total === 0) { + trackTable.clear(); + $tracksStatus.textContent = 'No tracks recorded for this year.'; + return; + } + + const maxPage = Math.max(1, Math.ceil(total / state.pageSize)); + if (state.page > maxPage) state.page = maxPage; + + const stmt = db.prepare(tracksRowsSql); + stmt.bind([state.selectedYear, state.pageSize, (state.page - 1) * state.pageSize]); + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + } catch (err) { + console.error(err); + $tracksStatus.textContent = 'Failed to load tracks.'; + return; + } + + trackTable.setRows(rows); + $tracksStatus.hidden = true; + trackTable.el.hidden = false; + pagination.setState({ + page: state.page, + pageSize: state.pageSize, + total, + resultsCount: rows.length, + }); + } + + const unbindRows = bindRowActivation(trackTable.el, (rowId) => { + const trackId = Number(rowId); + if (!Number.isFinite(trackId)) return; + UX.openOverlay(createTrackOverlay(db, trackId)); + }); + + $back.addEventListener('click', () => navigateTo('nav')); + + loadYears(); + if (Number.isFinite(state.selectedYear)) loadTracksImmediate(); + + return { + kind: 'base', + el, + onShow() { + if (yearButtons.length) { + const target = yearButtons.find((btn) => Number(btn.dataset.year) === state.selectedYear) || yearButtons[0]; + if (target) target.focus(); + } + }, + destroy() { + unbindRows(); + }, + }; + } + + function createBrowseGenresView(db, { presetGenre = null } = {}) { + const el = instantiateTemplate('tpl-browse-genres'); + const $back = el.querySelector('[data-action="back"]'); + const $genres = el.querySelector('[data-ref="genres"]'); + const $tracksSection = el.querySelector('[data-ref="tracks"]'); + const $selectedGenre = el.querySelector('[data-ref="selected-genre"]'); + + $tracksSection.hidden = true; + + const $genreStatus = document.createElement('p'); + $genreStatus.className = 'has-text-grey'; + $genreStatus.textContent = 'Loading genres…'; + $genres.innerHTML = ''; + $genres.appendChild($genreStatus); + + const $tracksStatus = document.createElement('p'); + $tracksStatus.className = 'has-text-grey'; + $tracksStatus.textContent = 'Select a genre to view tracks.'; + $tracksSection.innerHTML = ''; + $tracksSection.appendChild($selectedGenre); + $tracksSection.appendChild($tracksStatus); + + const trackTable = createTableRenderer({ + columns: [ + { header: 'Track', key: 'title' }, + { header: 'Artist', key: 'artist' }, + { header: 'Album', key: 'album' }, + { header: 'Year', key: 'year', className: 'has-text-right' }, + ], + emptyMessage: 'No tracks for this genre', + getRowId: (row) => row.id, + interactive: true, + }); + trackTable.el.hidden = true; + $tracksSection.appendChild(trackTable.el); + + const pagination = createPagination({ + onChange: ({ page, pageSize }) => { + state.page = page; + state.pageSize = pageSize; + loadTracksImmediate(); + }, + }); + $tracksSection.appendChild(pagination.el); + + const state = { + genres: [], + selectedGenre: presetGenre, + page: 1, + pageSize: pagination.pageSize, + }; + + let genreButtons = []; + + const genresSql = ` + SELECT genre, COUNT(*) AS cnt + FROM tracks + WHERE genre IS NOT NULL AND genre != '' + GROUP BY genre + ORDER BY cnt DESC, genre COLLATE NOCASE + `; + const tracksCountSql = 'SELECT COUNT(*) AS count FROM tracks WHERE genre = ?'; + const tracksRowsSql = ` + SELECT t.id, t.title, a.name AS artist, IFNULL(al.title, '') AS album, t.year + FROM tracks t + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.genre = ? + ORDER BY a.name COLLATE NOCASE, t.title COLLATE NOCASE + LIMIT ? OFFSET ? + `; + + function renderGenres() { + $genres.innerHTML = ''; + if (!state.genres.length) { + const empty = document.createElement('p'); + empty.className = 'has-text-grey'; + empty.textContent = 'No genres recorded.'; + $genres.appendChild(empty); + return; + } + + const list = document.createElement('div'); + list.className = 'buttons are-small is-flex is-flex-wrap-wrap'; + genreButtons = []; + state.genres.forEach((entry) => { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button is-light'; + btn.dataset.genre = entry.genre; + btn.innerHTML = `${escapeHtml(entry.genre)} (${formatNumber(entry.cnt)})`; + btn.addEventListener('click', () => { + state.selectedGenre = entry.genre; + state.page = 1; + updateGenreButtons(); + loadTracksImmediate(); + }); + list.appendChild(btn); + genreButtons.push(btn); + }); + $genres.appendChild(list); + updateGenreButtons(); + } + + function updateGenreButtons() { + genreButtons.forEach((btn) => { + if (btn.dataset.genre === state.selectedGenre) btn.classList.add('is-link'); + else btn.classList.remove('is-link'); + }); + } + + function loadGenres() { + try { + const stmt = db.prepare(genresSql); + const rows = []; + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + state.genres = rows.map((row) => ({ genre: row.genre, cnt: Number(row.cnt) })); + renderGenres(); + } catch (err) { + console.error(err); + $genres.innerHTML = '

Failed to load genres.

'; + } + } + + function loadTracksImmediate() { + if (!state.selectedGenre) { + trackTable.clear(); + trackTable.el.hidden = true; + $tracksSection.hidden = true; + pagination.setDisabled(true); + return; + } + + $tracksSection.hidden = false; + $selectedGenre.textContent = `Tracks tagged ${state.selectedGenre}`; + $tracksStatus.textContent = 'Loading tracks…'; + $tracksStatus.hidden = false; + trackTable.el.hidden = true; + pagination.setDisabled(true); + + let total = 0; + const rows = []; + try { + const countStmt = db.prepare(tracksCountSql); + countStmt.bind([state.selectedGenre]); + if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; + countStmt.free(); + + if (total === 0) { + trackTable.clear(); + $tracksStatus.textContent = 'No tracks found for this genre.'; + return; + } + + const maxPage = Math.max(1, Math.ceil(total / state.pageSize)); + if (state.page > maxPage) state.page = maxPage; + + const stmt = db.prepare(tracksRowsSql); + stmt.bind([state.selectedGenre, state.pageSize, (state.page - 1) * state.pageSize]); + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + } catch (err) { + console.error(err); + $tracksStatus.textContent = 'Failed to load tracks.'; + return; + } + + trackTable.setRows(rows); + $tracksStatus.hidden = true; + trackTable.el.hidden = false; + pagination.setState({ + page: state.page, + pageSize: state.pageSize, + total, + resultsCount: rows.length, + }); + } + + const unbindRows = bindRowActivation(trackTable.el, (rowId) => { + const trackId = Number(rowId); + if (!Number.isFinite(trackId)) return; + UX.openOverlay(createTrackOverlay(db, trackId)); + }); + + $back.addEventListener('click', () => navigateTo('nav')); + + loadGenres(); + if (state.selectedGenre) loadTracksImmediate(); + + return { + kind: 'base', + el, + onShow() { + if (genreButtons.length) { + const target = genreButtons.find((btn) => btn.dataset.genre === state.selectedGenre) || genreButtons[0]; + if (target) target.focus(); + } + }, + destroy() { + unbindRows(); + }, + }; + } + + function createStatsView(db) { + const el = instantiateTemplate('tpl-stats'); + const $back = el.querySelector('[data-action="back"]'); + const $cards = el.querySelector('[data-ref="cards"]'); + const $lists = el.querySelector('[data-ref="lists"]'); + + const totalsSql = ` + SELECT + (SELECT COUNT(*) FROM artists) AS artists, + (SELECT COUNT(*) FROM albums) AS albums, + (SELECT COUNT(*) FROM tracks) AS tracks + `; + const topArtistsSql = ` + SELECT a.id AS artist_id, a.name AS artist, COUNT(*) AS cnt + FROM tracks t + JOIN artists a ON a.id = t.artist_id + GROUP BY a.id + ORDER BY cnt DESC, a.name COLLATE NOCASE + LIMIT 10 + `; + const topYearsSql = ` + SELECT year, COUNT(*) AS cnt + FROM tracks + WHERE year IS NOT NULL + GROUP BY year + ORDER BY cnt DESC, year DESC + LIMIT 10 + `; + const topGenresSql = ` + SELECT genre, COUNT(*) AS cnt + FROM tracks + WHERE genre IS NOT NULL AND genre != '' + GROUP BY genre + ORDER BY cnt DESC, genre COLLATE NOCASE + LIMIT 10 + `; + + function renderTotals() { + try { + const stmt = db.prepare(totalsSql); + stmt.step(); + const totals = stmt.getAsObject(); + stmt.free(); + + const metrics = [ + { label: 'Artists', value: Number(totals.artists), action: () => navigateTo('browseArtists') }, + { label: 'Albums', value: Number(totals.albums), action: () => navigateTo('browseAlbums') }, + { label: 'Tracks', value: Number(totals.tracks), action: () => navigateTo('search') }, + ]; + + $cards.innerHTML = ''; + metrics.forEach((metric) => { + const column = document.createElement('div'); + column.className = 'column'; + const box = document.createElement('div'); + box.className = 'box has-text-centered'; + const value = document.createElement('p'); + value.className = 'title is-3'; + value.textContent = formatNumber(metric.value); + const label = document.createElement('p'); + label.className = 'subtitle is-6'; + label.textContent = metric.label; + const button = document.createElement('button'); + button.type = 'button'; + button.className = 'button is-small is-link'; + button.textContent = `Open ${metric.label}`; + button.addEventListener('click', metric.action); + box.appendChild(value); + box.appendChild(label); + box.appendChild(button); + column.appendChild(box); + $cards.appendChild(column); + }); + } catch (err) { + console.error(err); + $cards.innerHTML = '
Failed to load stats.
'; + } + } + + function renderTopLists() { + let topArtists = []; + let topYears = []; + let topGenres = []; + try { + const artistStmt = db.prepare(topArtistsSql); + while (artistStmt.step()) topArtists.push(artistStmt.getAsObject()); + artistStmt.free(); + + const yearStmt = db.prepare(topYearsSql); + while (yearStmt.step()) topYears.push(yearStmt.getAsObject()); + yearStmt.free(); + + const genreStmt = db.prepare(topGenresSql); + while (genreStmt.step()) topGenres.push(genreStmt.getAsObject()); + genreStmt.free(); + } catch (err) { + console.error(err); + $lists.innerHTML = '

Failed to load ranking lists.

'; + return; + } + + $lists.innerHTML = ''; + const columns = document.createElement('div'); + columns.className = 'columns'; + + const artistCol = document.createElement('div'); + artistCol.className = 'column'; + artistCol.appendChild(buildListBox('Top Artists', topArtists, (row) => { + UX.openOverlay(createArtistOverlay(db, Number(row.artist_id))); + }, (row) => `${row.artist} — ${formatNumber(row.cnt)} tracks`)); + + const yearCol = document.createElement('div'); + yearCol.className = 'column'; + yearCol.appendChild(buildListBox('Busiest Years', topYears, (row) => { + navigateTo('browseYears', { presetYear: Number(row.year) }); + }, (row) => `${row.year}: ${formatNumber(row.cnt)} tracks`)); + + const genreCol = document.createElement('div'); + genreCol.className = 'column'; + genreCol.appendChild(buildListBox('Top Genres', topGenres, (row) => { + navigateTo('browseGenres', { presetGenre: row.genre }); + }, (row) => `${row.genre} — ${formatNumber(row.cnt)} tracks`)); + + columns.appendChild(artistCol); + columns.appendChild(yearCol); + columns.appendChild(genreCol); + $lists.appendChild(columns); + } + + function buildListBox(title, rows, onActivate, getLabel) { + const box = document.createElement('div'); + box.className = 'box'; + const heading = document.createElement('h3'); + heading.className = 'title is-5'; + heading.textContent = title; + box.appendChild(heading); + if (!rows.length) { + const empty = document.createElement('p'); + empty.className = 'has-text-grey'; + empty.textContent = 'No data.'; + box.appendChild(empty); + return box; + } + const list = document.createElement('ul'); + list.className = 'menu-list'; + rows.forEach((row) => { + const li = document.createElement('li'); + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button is-text is-small has-text-left'; + btn.textContent = getLabel(row); + btn.addEventListener('click', () => onActivate(row)); + li.appendChild(btn); + list.appendChild(li); + }); + box.appendChild(list); + return box; + } + + $back.addEventListener('click', () => navigateTo('nav')); + + renderTotals(); + renderTopLists(); + + return { + kind: 'base', + el, + onShow() { + const firstButton = el.querySelector('button'); + if (firstButton) firstButton.focus(); + }, + destroy() {}, + }; + } + + function createArtistOverlay(db, artistId) { + const el = instantiateTemplate('tpl-artist'); + const $name = el.querySelector('[data-ref="name"]'); + const $meta = el.querySelector('[data-ref="meta"]'); + const $close = el.querySelector('[data-action="close"]'); + const tabListItems = Array.from(el.querySelectorAll('.tabs ul li')); + const tabLinks = Array.from(el.querySelectorAll('.tabs [data-tab]')); + const panels = { + albums: el.querySelector('[data-tabpanel="albums"]'), + tracks: el.querySelector('[data-tabpanel="tracks"]'), + }; + + let artistInfo = null; + + const headerSql = ` + SELECT a.name, + (SELECT COUNT(*) FROM albums WHERE artist_id = a.id) AS album_count, + (SELECT COUNT(*) FROM tracks WHERE artist_id = a.id) AS track_count + FROM artists a + WHERE a.id = ? + `; + const albumsSql = ` + SELECT id, title, year + FROM albums + WHERE artist_id = ? + ORDER BY COALESCE(year, 0), title COLLATE NOCASE + `; + const tracksSql = ` + SELECT id, title, year, genre + FROM tracks + WHERE artist_id = ? + ORDER BY COALESCE(year, 0), title COLLATE NOCASE + LIMIT 100 + `; + + const albumStatus = document.createElement('p'); + albumStatus.className = 'has-text-grey'; + albumStatus.textContent = 'Loading albums…'; + panels.albums.appendChild(albumStatus); + + const albumTable = createTableRenderer({ + columns: [ + { header: 'Year', key: 'year', className: 'has-text-right' }, + { header: 'Album', key: 'title' }, + ], + emptyMessage: 'No albums recorded.', + getRowId: (row) => row.id, + interactive: true, + }); + albumTable.el.hidden = true; + panels.albums.appendChild(albumTable.el); + + const trackStatus = document.createElement('p'); + trackStatus.className = 'has-text-grey'; + trackStatus.textContent = 'Loading tracks…'; + panels.tracks.appendChild(trackStatus); + + const trackTable = createTableRenderer({ + columns: [ + { header: 'Title', key: 'title' }, + { header: 'Year', key: 'year', className: 'has-text-right' }, + { header: 'Genre', key: 'genre' }, + ], + emptyMessage: 'No tracks recorded.', + getRowId: (row) => row.id, + interactive: true, + }); + trackTable.el.hidden = true; + panels.tracks.appendChild(trackTable.el); + + const loaded = new Set(); + + function loadHeader() { + try { + const stmt = db.prepare(headerSql); + stmt.bind([artistId]); + if (stmt.step()) { + artistInfo = stmt.getAsObject(); + $name.textContent = artistInfo.name; + const albumCount = formatNumber(artistInfo.album_count); + const trackCount = formatNumber(artistInfo.track_count); + $meta.textContent = `${albumCount} album${artistInfo.album_count === 1 ? '' : 's'} • ${trackCount} track${artistInfo.track_count === 1 ? '' : 's'}`; + } else { + $name.textContent = 'Unknown artist'; + $meta.textContent = ''; + } + stmt.free(); + } catch (err) { + console.error(err); + $meta.textContent = 'Failed to load artist details.'; + } + } + + function loadAlbums() { + if (loaded.has('albums')) return; + loaded.add('albums'); + try { + const rows = []; + const stmt = db.prepare(albumsSql); + stmt.bind([artistId]); + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + albumTable.setRows(rows); + albumStatus.hidden = true; + albumTable.el.hidden = false; + } catch (err) { + console.error(err); + albumStatus.textContent = 'Failed to load albums.'; + } + } + + function loadTracks() { + if (loaded.has('tracks')) return; + loaded.add('tracks'); + try { + const rows = []; + const stmt = db.prepare(tracksSql); + stmt.bind([artistId]); + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + trackTable.setRows(rows); + trackStatus.hidden = true; + trackTable.el.hidden = false; + } catch (err) { + console.error(err); + trackStatus.textContent = 'Failed to load tracks.'; + } + } + + function setActiveTab(tab) { + Object.keys(panels).forEach((key, index) => { + const panel = panels[key]; + const li = tabListItems[index]; + const link = tabLinks[index]; + const isActive = key === tab; + if (panel) panel.hidden = !isActive; + if (li) { + if (isActive) li.classList.add('is-active'); + else li.classList.remove('is-active'); + } + if (link) link.setAttribute('aria-selected', isActive ? 'true' : 'false'); + }); + if (tab === 'albums') loadAlbums(); + else if (tab === 'tracks') loadTracks(); + } + + tabLinks.forEach((link) => { + link.addEventListener('click', (event) => { + event.preventDefault(); + const tab = link.dataset.tab; + if (!tab) return; + setActiveTab(tab); + }); + }); + + $close.addEventListener('click', () => UX.closeTop()); + + const unbindAlbumRows = bindRowActivation(albumTable.el, (rowId) => { + const albumId = Number(rowId); + if (!Number.isFinite(albumId)) return; + UX.openOverlay(createAlbumOverlay(db, albumId)); + }); + const unbindTrackRows = bindRowActivation(trackTable.el, (rowId) => { + const trackId = Number(rowId); + if (!Number.isFinite(trackId)) return; + UX.openOverlay(createTrackOverlay(db, trackId)); + }); + + loadHeader(); + setActiveTab('albums'); + + return { + kind: 'overlay', + el, + onShow() { + $close.focus(); + }, + destroy() { + unbindAlbumRows(); + unbindTrackRows(); + }, + }; + } + + function createAlbumOverlay(db, albumId) { + const el = instantiateTemplate('tpl-album'); + const $title = el.querySelector('[data-ref="title"]'); + const $meta = el.querySelector('[data-ref="meta"]'); + const $close = el.querySelector('[data-action="close"]'); + const $tracks = el.querySelector('[data-ref="tracks"]'); + + const headerSql = ` + SELECT al.title, al.year, a.name AS artist, a.id AS artist_id + FROM albums al + JOIN artists a ON a.id = al.artist_id + WHERE al.id = ? + `; + const tracksSql = ` + SELECT id, track_no, title, duration_sec, bitrate_kbps + FROM tracks + WHERE album_id = ? + ORDER BY COALESCE(track_no, 999), title COLLATE NOCASE + `; + + const trackTable = createTableRenderer({ + columns: [ + { header: '#', render: (row) => (row.track_no ? row.track_no : '—'), className: 'has-text-right' }, + { header: 'Title', key: 'title' }, + { header: 'Duration', render: (row) => formatDuration(Number(row.duration_sec)) }, + { header: 'Bitrate', render: (row) => formatBitrate(Number(row.bitrate_kbps)) }, + ], + emptyMessage: 'No tracks found for this album.', + getRowId: (row) => row.id, + interactive: true, + }); + trackTable.el.hidden = true; + $tracks.innerHTML = ''; + const $status = document.createElement('p'); + $status.className = 'has-text-grey'; + $status.textContent = 'Loading tracks…'; + $tracks.appendChild($status); + $tracks.appendChild(trackTable.el); + + let artistId = null; + + function loadHeader() { + try { + const stmt = db.prepare(headerSql); + stmt.bind([albumId]); + if (stmt.step()) { + const info = stmt.getAsObject(); + artistId = Number(info.artist_id); + $title.textContent = info.title || 'Untitled album'; + const parts = []; + if (info.artist) parts.push(info.artist); + if (info.year) parts.push(String(info.year)); + $meta.textContent = parts.join(' • '); + } else { + $title.textContent = 'Unknown album'; + $meta.textContent = ''; + } + stmt.free(); + } catch (err) { + console.error(err); + $meta.textContent = 'Failed to load album details.'; + } + } + + function loadTracks() { + try { + const rows = []; + const stmt = db.prepare(tracksSql); + stmt.bind([albumId]); + while (stmt.step()) rows.push(stmt.getAsObject()); + stmt.free(); + trackTable.setRows(rows); + $status.hidden = true; + trackTable.el.hidden = false; + } catch (err) { + console.error(err); + $status.textContent = 'Failed to load tracks.'; + } + } + + $close.addEventListener('click', () => UX.closeTop()); + + let metaActions = null; + + function renderMetaActions() { + if (!$meta) return; + if (!metaActions) { + metaActions = document.createElement('div'); + metaActions.className = 'mt-2'; + } + metaActions.innerHTML = ''; + if (Number.isFinite(artistId) && artistId) { + const artistBtn = document.createElement('button'); + artistBtn.type = 'button'; + artistBtn.className = 'button is-small is-link'; + artistBtn.textContent = 'View artist'; + artistBtn.addEventListener('click', () => { + UX.openOverlay(createArtistOverlay(db, artistId)); + }); + metaActions.appendChild(artistBtn); + } + if (metaActions.children.length && !metaActions.parentNode) { + $tracks.parentNode.insertBefore(metaActions, $tracks); + } + if (!metaActions.children.length && metaActions.parentNode) { + metaActions.parentNode.removeChild(metaActions); + } + } + + const unbindRows = bindRowActivation(trackTable.el, (rowId) => { + const trackId = Number(rowId); + if (!Number.isFinite(trackId)) return; + UX.openOverlay(createTrackOverlay(db, trackId)); + }); + + loadHeader(); + renderMetaActions(); + loadTracks(); + + return { + kind: 'overlay', + el, + onShow() { + $close.focus(); + }, + destroy() { + unbindRows(); + }, + }; + } + + function createTrackOverlay(db, trackId) { + const el = instantiateTemplate('tpl-track'); + const $title = el.querySelector('[data-ref="title"]'); + const $meta = el.querySelector('[data-ref="meta"]'); + const $details = el.querySelector('[data-ref="details"]'); + const $close = el.querySelector('[data-action="close"]'); + const $copy = el.querySelector('[data-action="copy-path"]'); + + const sql = ` + SELECT t.id, t.title, t.year, t.genre, t.duration_sec, t.bitrate_kbps, t.samplerate_hz, + t.channels, t.filesize_bytes, t.sha1, t.relpath, + a.id AS artist_id, a.name AS artist, + al.id AS album_id, al.title AS album + FROM tracks t + JOIN artists a ON a.id = t.artist_id + LEFT JOIN albums al ON al.id = t.album_id + WHERE t.id = ? + `; + + let track = null; + + function loadTrack() { + try { + const stmt = db.prepare(sql); + stmt.bind([trackId]); + if (stmt.step()) { + track = stmt.getAsObject(); + $title.textContent = track.title || 'Untitled track'; + const metaParts = []; + if (track.artist) metaParts.push(track.artist); + if (track.album) metaParts.push(track.album); + if (track.year) metaParts.push(String(track.year)); + $meta.textContent = metaParts.join(' • '); + renderDetails(); + } else { + $title.textContent = 'Track not found'; + $meta.textContent = ''; + } + stmt.free(); + } catch (err) { + console.error(err); + $meta.textContent = 'Failed to load track details.'; + } + } + + function renderDetails() { + if (!track) return; + $details.innerHTML = ''; + + addDetail('Artist', track.artist || '—', track.artist_id ? () => UX.openOverlay(createArtistOverlay(db, Number(track.artist_id))) : null); + if (track.album) { + addDetail('Album', track.album, track.album_id ? () => UX.openOverlay(createAlbumOverlay(db, Number(track.album_id))) : null); + } + addDetail('Year', track.year || '—'); + addDetail('Genre', track.genre || '—'); + addDetail('Duration', formatDuration(Number(track.duration_sec))); + addDetail('Bitrate', formatBitrate(Number(track.bitrate_kbps))); + addDetail('Sample rate', formatSamplerate(Number(track.samplerate_hz))); + addDetail('Channels', track.channels ? `${track.channels}` : '—'); + addDetail('File size', Number(track.filesize_bytes) ? formatBytes(Number(track.filesize_bytes)) : '—'); + if (track.sha1) addDetail('SHA-1', track.sha1); + addDetail('Path', track.relpath || '—'); + } + + function addDetail(label, value, action) { + const term = document.createElement('dt'); + term.className = 'column is-one-quarter-tablet is-one-third-desktop has-text-weight-semibold'; + term.textContent = label; + const def = document.createElement('dd'); + def.className = 'column is-three-quarters-tablet is-two-thirds-desktop'; + if (action) { + const btn = document.createElement('button'); + btn.type = 'button'; + btn.className = 'button is-small is-text'; + btn.textContent = value; + btn.addEventListener('click', action); + def.appendChild(btn); + } else { + def.textContent = value; + } + $details.appendChild(term); + $details.appendChild(def); + } + + async function handleCopy() { + if (!track || !track.relpath) return; + const text = track.relpath; + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + const area = document.createElement('textarea'); + area.value = text; + area.setAttribute('readonly', ''); + area.style.position = 'absolute'; + area.style.left = '-9999px'; + document.body.appendChild(area); + area.select(); + document.execCommand('copy'); + document.body.removeChild(area); + } + $copy.textContent = 'Copied!'; + setTimeout(() => { $copy.textContent = 'Copy Path'; }, 1600); + } catch (err) { + console.error(err); + $copy.textContent = 'Copy failed'; + setTimeout(() => { $copy.textContent = 'Copy Path'; }, 1600); + } + } + + $close.addEventListener('click', () => UX.closeTop()); + $copy.addEventListener('click', handleCopy); + + loadTrack(); + + return { + kind: 'overlay', + el, + onShow() { + $close.focus(); + }, + destroy() {}, + }; + } + + const viewFactories = { + nav: createNavView, + search: createSearchView, + browseArtists: createBrowseArtistsView, + browseAlbums: createBrowseAlbumsView, + browseYears: createBrowseYearsView, + browseGenres: createBrowseGenresView, + stats: createStatsView, + }; + + let activeDb = null; + + function navigateTo(view, params) { + if (!activeDb) throw new Error('Database not ready'); + const factory = viewFactories[view]; + if (!factory) throw new Error(`Unknown view: ${view}`); + const instance = factory(activeDb, params || {}); + return UX.replace(instance); + } + // --- Main bootstrap --- loader = createLoaderView(); UX.replace(loader); @@ -718,9 +2247,10 @@ loader.setDetail('Opening database'); const db = new SQL.Database(dbBytes); - const search = createSearchView(db); - await UX.replace(search); + activeDb = db; window.__db = db; + window.__navigateTo = navigateTo; + await navigateTo('nav'); } catch (err) { console.error(err); try { diff --git a/site.css b/site.css index ace9d1b..1830dda 100644 --- a/site.css +++ b/site.css @@ -11,3 +11,17 @@ .ux-overlays { position: absolute; inset: 0; pointer-events: none; } .ux-view { width: 100%; } .ux-view--overlay { position: absolute; inset: 0; z-index: 10; pointer-events: auto; } + +.is-sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} + +.table tbody tr.is-selectable-row { cursor: pointer; } From 7ee735c2db9de7ee3dd8970d2d1eb8a2e6a571bd Mon Sep 17 00:00:00 2001 From: wagesj45 Date: Tue, 16 Sep 2025 22:56:01 -0500 Subject: [PATCH 4/4] 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, }); }