diff --git a/webui/index.html b/webui/index.html index 0d8769de..91b456a3 100644 --- a/webui/index.html +++ b/webui/index.html @@ -354,6 +354,11 @@ + +

Tools & Operations

diff --git a/webui/static/mobile.css b/webui/static/mobile.css index 5ead6d16..b58362ac 100644 --- a/webui/static/mobile.css +++ b/webui/static/mobile.css @@ -101,6 +101,10 @@ padding: 15px; } + #artist-hero-section #artist-detail-image { + width: 100% !important + } + #media-player { min-height: fit-content; } diff --git a/webui/static/script.js b/webui/static/script.js index 5aca513f..4b3271f3 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -10163,6 +10163,25 @@ function escapeHtml(text) { return div.innerHTML; } +/** + * Escape a value for safe use inside a single-quoted JS string literal + * within a double-quoted HTML attribute (e.g. onclick="fn('${val}')"). + * + * Layer 1 (JS): escape \ and ' so the JS string parses correctly. + * Layer 2 (HTML): escape &, ", <, > so the HTML attribute parses correctly. + * The browser applies these in reverse: HTML-decode first, then JS-execute. + */ +function escapeForInlineJs(str) { + if (str == null) return ''; + return String(str) + .replace(/\\/g, '\\\\') // JS: literal backslash + .replace(/'/g, "\\'") // JS: single quote + .replace(/&/g, '&') // HTML: ampersand + .replace(/"/g, '"') // HTML: double quote + .replace(//g, '>'); // HTML: greater-than +} + function formatArtists(artists) { if (!artists || !Array.isArray(artists)) { return 'Unknown Artist'; @@ -13763,6 +13782,9 @@ async function loadDashboardData() { // Check for any active download processes that need rehydration await checkForActiveProcesses(); + // Populate the Active Downloads dashboard section with any existing downloads + updateDashboardDownloads(); + // Automatic wishlist processing now runs server-side } @@ -13791,6 +13813,123 @@ function updateDashboardStatCards(stats) { // For now, we focus on the updater tool itself. } +/** + * Update the Active Downloads section on the dashboard. + * Called from artist, search, and discover update points (event-driven, no polling). + */ +function updateDashboardDownloads() { + const section = document.getElementById('dashboard-active-downloads-section'); + const container = document.getElementById('dashboard-downloads-container'); + if (!section || !container) return; + + // Collect active entries from each source + const activeArtists = Object.keys(artistDownloadBubbles).filter(id => + artistDownloadBubbles[id].downloads.length > 0 + ); + const activeSearch = Object.keys(searchDownloadBubbles).filter(name => + searchDownloadBubbles[name].downloads.length > 0 + ); + const activeDiscover = Object.keys(discoverDownloads); + + const totalCount = activeArtists.length + activeSearch.length + activeDiscover.length; + + if (totalCount === 0) { + section.style.display = 'none'; + container.innerHTML = ''; + return; + } + + section.style.display = ''; + let html = ''; + + // --- Artists group --- + if (activeArtists.length > 0) { + html += ` +
+
+ Artists + ${activeArtists.length} +
+
+ ${activeArtists.map(id => createArtistBubbleCard(artistDownloadBubbles[id])).join('')} +
+
`; + } + + // --- Search group --- + if (activeSearch.length > 0) { + html += ` +
+
+ Search + ${activeSearch.length} +
+
+ ${activeSearch.map(name => createSearchBubbleCard(searchDownloadBubbles[name])).join('')} +
+
`; + } + + // --- Discover group --- + if (activeDiscover.length > 0) { + html += ` +
+
+ Discover + ${activeDiscover.length} +
+
+ ${activeDiscover.map(pid => createDashboardDiscoverBubble(pid)).join('')} +
+
`; + } + + container.innerHTML = html; + + // Post-render: attach artist bubble click handlers + dynamic glow + activeArtists.forEach(artistId => { + const card = container.querySelector(`.artist-bubble-card[data-artist-id="${artistId}"]`); + if (card) { + card.addEventListener('click', () => openArtistDownloadModal(artistId)); + const artist = artistDownloadBubbles[artistId].artist; + if (artist.image_url) { + extractImageColors(artist.image_url, (colors) => { + applyDynamicGlow(card, colors); + }); + } + } + }); + // Search and discover cards use inline onclick — no post-render needed +} + +/** + * Create a 150px circle card for a discover download (dashboard variant). + * Matches artist/search bubble sizing. + */ +function createDashboardDiscoverBubble(playlistId) { + const download = discoverDownloads[playlistId]; + if (!download) return ''; + + const isCompleted = download.status === 'completed'; + const imageUrl = download.imageUrl || ''; + const backgroundStyle = imageUrl + ? `background-image: url('${imageUrl}');` + : `background: linear-gradient(135deg, rgba(29, 185, 84, 0.3) 0%, rgba(24, 156, 71, 0.2) 100%);`; + + return ` +
+
+
+
+
${escapeHtml(download.name)}
+
${isCompleted ? 'Completed' : 'In Progress'}
+
+
+ `; +} + function updateDbUpdaterCardInfo(stats) { @@ -20669,7 +20808,7 @@ function createArtistCardHTML(artist) { ${popularityText}
- @@ -22414,6 +22553,7 @@ function updateArtistDownloadsSection() { } downloadsUpdateTimeout = setTimeout(() => { showArtistDownloadsSection(); + updateDashboardDownloads(); }, 300); // 300ms debounce } @@ -22713,6 +22853,7 @@ function updateSearchDownloadsSection() { } window.searchUpdateTimeout = setTimeout(() => { showSearchDownloadBubbles(); + updateDashboardDownloads(); }, 300); } @@ -22825,7 +22966,7 @@ function createSearchBubbleCard(artistBubbleData) { return `
@@ -22838,7 +22979,7 @@ function createSearchBubbleCard(artistBubbleData) {
${allCompleted ? `
✅
@@ -31931,7 +32072,7 @@ async function loadGenreBrowser() { const icon = getGenreIcon(genre.name); const displayName = capitalizeGenre(genre.name); html += ` -
+
${icon}
@@ -32479,7 +32620,7 @@ async function loadGenreBrowserTabs() { tabsHTML += ` `; @@ -32495,11 +32636,11 @@ async function loadGenreBrowserTabs() {

${genre.track_count} tracks

- - @@ -33006,7 +33147,7 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) {