diff --git a/AGENTS.md b/AGENTS.md index 8415acf..1b8476e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -197,92 +197,3 @@ 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 -- [x] Add small table/list renderer util in `script.js` to build rows safely (uses `escapeHtml`). Implemented via `createTableRenderer`. -- [x] Add shared pagination component (Prev/Next, page size select). Propagate `LIMIT/OFFSET`. Implemented via `createPagination`. -- [x] Add 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". -- [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 ?`. -- [x] Column sorts (toggle rank vs artist,title,year). -- [x] Row activation opens Track overlay. - -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 ?`. -- [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. -- [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 -- [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 -- [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 -- [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 -- [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`. -- [x] Actions: clicking album opens Album overlay; clicking track opens Track overlay. - -Album Overlay -- [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`. -- [x] Row activation opens Track overlay. - -Track Overlay -- [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) -- [ ] `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 9be3e96..4ad75a1 100644 --- a/index.html +++ b/index.html @@ -44,8 +44,7 @@
- - +

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

@@ -57,289 +56,6 @@ - - - - - - - - - - - - - - - - - - diff --git a/script.js b/script.js index fcc437c..e8403e2 100644 --- a/script.js +++ b/script.js @@ -103,7 +103,7 @@ } window.addEventListener('keydown', (e) => { - if (Keyboard.isEscapeKey(e) && overlayStack.length) { + if (e.key === 'Escape' && overlayStack.length) { e.preventDefault(); closeTop(); } @@ -191,30 +191,6 @@ 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'); @@ -250,400 +226,6 @@ return root; } - 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'); - 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); - } - 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; - 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); - }); - if (typeof onRowRender === 'function') onRowRender(tr, row); - 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); - }, - }; - } - - 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) { - 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 (!Keyboard.isActivationKey(event)) 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); - }; - } - - 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], - 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"]'); @@ -670,1652 +252,43 @@ }; } - function createSearchView(db, { initialQuery = '' } = {}) { + function createSearchView(db) { 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 = ''; - $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, - interactive: true, - }); - 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 listState = createAsyncListState({ table, statusEl: $status, pagination }); - - const state = { - 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'); - }); + function renderStub(qVal) { + if (!qVal) { + $results.innerHTML = '

Type to search…

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

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

`; + } } - 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 enableSearch() { + function enable() { $q.disabled = false; $q.removeAttribute('aria-disabled'); - if (state.query) { - $q.value = state.query; - } $q.focus(); } - function runSearchImmediate() { - const term = state.query.trim(); - if (!term) { - table.clear(); - listState.showIdle('Type to search…'); - return; - } - - listState.showLoading('Searching…'); - - 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(); - listState.showEmpty('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); - listState.showError('Search failed. Check console for details.'); - return; - } - - listState.showRows({ - rows, - total, - page: state.page, - pageSize: state.pageSize, - }); - } - - const runSearch = debounce(runSearchImmediate, 250); - $form.addEventListener('submit', (e) => e.preventDefault()); $q.addEventListener('input', () => { - state.query = $q.value; - 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)); + const q = $q.value.trim(); + renderStub(q); + // Future: db.prepare / db.exec queries }); - updateSortButtons(); - listState.showIdle('Type to search…'); + renderStub(''); return { kind: 'base', el, - onShow() { - 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(); - }, + onShow() { enable(); }, 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 listState = createAsyncListState({ table, statusEl: $status, pagination }); - - const state = { - prefix: initialPrefix, - filter: initialFilter, - page: 1, - pageSize: pagination.pageSize, - }; - - function escapeLike(str) { - return String(str).replace(/[\\%_]/g, (m) => `\\${m}`); - } - - function buildLikeTerm() { - 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 ? - `; - 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 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() { - 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 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([likeTerm]); - if (countStmt.step()) total = Number(countStmt.getAsObject().count) || 0; - countStmt.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); - listState.showError('Failed to load artists.'); - return; - } - - if (total === 0) { - table.clear(); - listState.showEmpty('No artists found'); - return; - } - - listState.showRows({ - rows, - total, - page: state.page, - pageSize: state.pageSize, - }); - } - - 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(); - listState.showLoading('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 listState = createAsyncListState({ table, statusEl: $status, pagination }); - - 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 loadAlbumsImmediate() { - listState.showLoading('Loading…'); - - 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(); - listState.showEmpty('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); - listState.showError('Failed to load albums.'); - return; - } - - listState.showRows({ - rows, - total, - page: state.page, - pageSize: state.pageSize, - }); - } - - 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)); - }); - - listState.showLoading('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 trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination }); - - 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(); - trackListState.showIdle('Select a year to view tracks.'); - return; - } - - trackListState.showLoading('Loading tracks…'); - - 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(); - trackListState.showEmpty('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); - trackListState.showError('Failed to load tracks.'); - return; - } - - trackListState.showRows({ - rows, - total, - page: state.page, - pageSize: state.pageSize, - }); - } - - 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 trackListState = createAsyncListState({ table: trackTable, statusEl: $tracksStatus, pagination }); - - 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(); - $tracksSection.hidden = true; - trackListState.showIdle('Select a genre to view tracks.'); - return; - } - - $tracksSection.hidden = false; - $selectedGenre.textContent = `Tracks tagged ${state.selectedGenre}`; - trackListState.showLoading('Loading tracks…'); - - 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(); - trackListState.showEmpty('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); - trackListState.showError('Failed to load tracks.'); - return; - } - - trackListState.showRows({ - rows, - total, - page: state.page, - pageSize: state.pageSize, - }); - } - - 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); @@ -2339,10 +312,9 @@ loader.setDetail('Opening database'); const db = new SQL.Database(dbBytes); - activeDb = db; + const search = createSearchView(db); + await UX.replace(search); window.__db = db; - window.__navigateTo = navigateTo; - await navigateTo('nav'); } catch (err) { console.error(err); try { @@ -2363,3 +335,4 @@ .replace(/'/g, '''); } })(); + diff --git a/site.css b/site.css index 1830dda..ace9d1b 100644 --- a/site.css +++ b/site.css @@ -11,17 +11,3 @@ .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; }