No data yet.
-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.
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); @@ -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; }