From e8982162c07372a8498ce4f411b2684475c8dde2 Mon Sep 17 00:00:00 2001 From: Jonathan Sykes Date: Thu, 15 Oct 2026 15:34:08 +0800 Subject: [PATCH] Admin home: song library with coverage bar, need/have filters, artwork and paging; phone tab bar always rests on whole tabs --- frontend/admin.html | 238 ++++++++++++++++++++++++++++++++++---------- 1 file changed, 184 insertions(+), 54 deletions(-) diff --git a/frontend/admin.html b/frontend/admin.html index a8cabc4..1911239 100644 --- a/frontend/admin.html +++ b/frontend/admin.html @@ -6,6 +6,7 @@ Lyrics Studio + @@ -924,11 +1002,37 @@ $('tabsTop').innerHTML = html; $('tabsBottom').innerHTML = html; document.querySelectorAll('[data-tab]').forEach((b) => b.addEventListener('click', () => showTab(b.dataset.tab))); } + /* Phone tab strip: every tab is exactly 1/n of the strip and snapping is + mandatory, so wherever it comes to rest it shows n whole tabs, never a + sliver of the next one. */ + const navEl = () => $('tabsBottom'); + function sizeNav() { + const nav = navEl(), w = nav.clientWidth; if (!w) return; + const n = Math.max(4, Math.min(7, Math.floor(w / 78))); + nav.style.setProperty('--tab-w', `${w / n}px`); + nav.dataset.perPage = n; railNav(); + } + function railNav() { + const nav = navEl(), total = nav.scrollWidth, w = nav.clientWidth, rail = $('navRail'); + if (!rail || !total) return; + rail.style.width = `${(w / total) * 100}%`; + rail.style.transform = `translateX(${(nav.scrollLeft / w) * 100}%)`; + } + function revealTab(name) { + const nav = navEl(), w = parseFloat(nav.style.getPropertyValue('--tab-w')); if (!w) return; + const i = TABS.findIndex(([k]) => k === name), n = Number(nav.dataset.perPage) || 5; + const first = Math.round(nav.scrollLeft / w); + if (i < first) nav.scrollTo({ left: i * w }); + else if (i >= first + n) nav.scrollTo({ left: (i - n + 1) * w }); + } + addEventListener('resize', sizeNav); + navEl().addEventListener('scroll', railNav, { passive: true }); function showTab(name, push = true) { if (!$('p-' + name)) name = 'editor'; tab = name; document.querySelectorAll('.panel').forEach((p) => p.classList.toggle('on', p.id === 'p-' + name)); document.querySelectorAll('[data-tab]').forEach((b) => b.setAttribute('aria-selected', String(b.dataset.tab === name))); + revealTab(name); if (push) history.replaceState(null, '', location.pathname + location.search + '#' + name); syncBars(); if (name === 'reports') loadReports(); @@ -966,6 +1070,7 @@ $('loginBox').classList.add('hidden'); $('app').classList.remove('hidden'); if (!$('tabsTop').children.length) buildTabs(); + sizeNav(); renderDocs(); loadRecent(); loadSongs(); @@ -1640,32 +1745,57 @@ }); async function loadRecent() { const j = await api('/api/uploads?limit=10'); - $('recentChips').innerHTML = (j.uploads || []).map((u) => ``).join(''); + $('recentChips').innerHTML = (j.uploads || []).map((u) => ``).join(''); } let SONGS = []; async function loadSongs() { const [m, u] = await Promise.all([api('/api/admin/media'), api('/api/uploads?limit=200')]); const seen = new Set(); SONGS = []; - for (const r of (m.media || [])) { seen.add(r.id); SONGS.push({ id: r.id, title: r.title || r.id, sub: r.channel, lines: r.lyricsLines || 0, sourceUrl: r.sourceUrl }); } - for (const r of (u.uploads || [])) if (!seen.has(r.id)) SONGS.push({ id: r.id, title: r.title, sub: r.artist || 'Upload', lines: -1 }); + for (const r of (m.media || [])) { seen.add(r.id); SONGS.push({ id: r.id, title: r.title || r.id, sub: r.channel, lines: r.lyricsLines || 0, sourceUrl: r.sourceUrl, dur: r.duration || 0 }); } + for (const r of (u.uploads || [])) if (!seen.has(r.id)) SONGS.push({ id: r.id, title: r.title, sub: r.artist || 'Upload', lines: -1, dur: r.duration || 0 }); paintSongs(); } + let songView = 'all', songLimit = 30; + const SONG_ART = ''; + const songArt = (id) => /^[A-Za-z0-9_-]{11}$/.test(id) ? `https://i.ytimg.com/vi/${id}/mqdefault.jpg` : `/api/uploads/${encodeURIComponent(id)}/art`; + const songDur = (s) => { s = Math.round(Number(s) || 0); return s > 0 ? `${Math.floor(s / 60)}:${String(s % 60).padStart(2, '0')}` : ''; }; function paintSongs() { const q = $('songFilter').value.trim().toLowerCase(); - const rows = SONGS.filter((r) => !q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q)); - $('songList').innerHTML = rows.length ? rows.map((r) => `
+ const has = SONGS.filter((r) => r.lines > 0).length, need = SONGS.filter((r) => r.lines === 0).length, known = has + need; + const counts = { all: SONGS.length, need, has }; + document.querySelectorAll('[data-n]').forEach((el) => { el.textContent = counts[el.dataset.n].toLocaleString(); }); + document.querySelectorAll('[data-view]').forEach((b) => b.setAttribute('aria-pressed', String(b.dataset.view === songView))); + $('songCount').textContent = SONGS.length ? `${SONGS.length.toLocaleString()} songs` : ''; + $('coverage').hidden = !known; + if (known) { + $('covFill').style.width = `${(has / known) * 100}%`; + $('covText').innerHTML = `${has.toLocaleString()} of ${known.toLocaleString()} downloaded songs have lyrics${need ? ` · ${need.toLocaleString()} still need them` : ' · all done'}`; + } + const rows = SONGS.filter((r) => (songView === 'all' || (songView === 'has' ? r.lines > 0 : r.lines === 0)) + && (!q || (r.title + ' ' + (r.sub || '')).toLowerCase().includes(q))); + const shown = rows.slice(0, songLimit); + $('songList').innerHTML = shown.length ? shown.map((r) => `
${r.sourceUrl ? `SourceOpen source page` : ''}
`).join('') - : '
Nothing matches — paste a link above to open any YouTube song.
'; + : `
${SONGS.length ? 'Nothing matches. Paste a link above to open any YouTube song.' : 'No songs on the server yet. Paste a link above to open any YouTube song.'}
`; + const more = rows.length - shown.length; + $('songMore').classList.toggle('hidden', more <= 0); + $('songMore').textContent = `Show ${Math.min(more, 30)} more of ${more.toLocaleString()}`; paintWhisper(); } - $('songFilter').addEventListener('input', paintSongs); + $('songMore').addEventListener('click', () => { songLimit += 30; paintSongs(); }); + document.querySelector('.lib-filter').addEventListener('click', (e) => { + const b = e.target.closest('[data-view]'); if (!b) return; + songView = b.dataset.view; songLimit = 30; paintSongs(); + }); + $('songFilter').addEventListener('input', () => { songLimit = 30; paintSongs(); }); $('songList').addEventListener('click', (e) => { const whisper = e.target.closest('[data-whisper]'); if (whisper) { startWhisper(whisper.dataset.whisper); return; } const source = e.target.closest('[data-redownload]'); if (source) { startSourceRedownload(source); return; }