No data yet.
+diff --git a/AGENTS.md b/AGENTS.md index 1b8476e..8415acf 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 +- [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 4ad75a1..9be3e96 100644 --- a/index.html +++ b/index.html @@ -44,7 +44,8 @@
Powered by in-browser SQLite FTS; no network queries.
Pick a view to explore the catalog.
+No data yet.
+No data yet.
+No data yet.
+Select a year to view tracks.
+No data yet.
+Loading…
+Loading…
+Type to search…
'; - } else { - $results.innerHTML = `Search stub — ready for query: ${escapeHtml(qVal)}
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 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); @@ -312,9 +2339,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 { @@ -335,4 +2363,3 @@ .replace(/'/g, '''); } })(); - 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; }