Add Active Downloads dashboard and UI fixes

Introduce an "Active Downloads" section to the dashboard and wire up client-side plumbing to populate and update it. Adds escapeForInlineJs to safely embed values into inline JS attributes and replaces several inline onclick usages (search/genre/listenbrainz/artist buttons) to prevent quoting issues. Implements updateDashboardDownloads, createDashboardDiscoverBubble, and integrates dashboard updates into artist/search/discover flows (including register/discover download persistence and monitor hooks). Adds dashboard-specific CSS for discover/artist bubbles and minor style fixes (artist image sizing, keyframe formatting) plus a mobile CSS tweak for artist images.
pull/153/head
Broque Thomas 3 months ago
parent 49a6c58ea8
commit e7dfb423dc

@ -354,6 +354,11 @@
</div>
</div>
<div class="dashboard-section" id="dashboard-active-downloads-section" style="display: none;">
<h3 class="section-title">Active Downloads</h3>
<div id="dashboard-downloads-container"></div>
</div>
<div class="dashboard-section">
<h3 class="section-title">Tools & Operations</h3>
<div class="tools-grid">

@ -101,6 +101,10 @@
padding: 15px;
}
#artist-hero-section #artist-detail-image {
width: 100% !important
}
#media-player {
min-height: fit-content;
}

@ -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, '&amp;') // HTML: ampersand
.replace(/"/g, '&quot;') // HTML: double quote
.replace(/</g, '&lt;') // HTML: less-than
.replace(/>/g, '&gt;'); // 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 += `
<div class="dashboard-downloads-group">
<div class="dashboard-downloads-group-header">
<span class="dashboard-downloads-group-label">Artists</span>
<span class="dashboard-downloads-group-count">${activeArtists.length}</span>
</div>
<div class="dashboard-bubble-container">
${activeArtists.map(id => createArtistBubbleCard(artistDownloadBubbles[id])).join('')}
</div>
</div>`;
}
// --- Search group ---
if (activeSearch.length > 0) {
html += `
<div class="dashboard-downloads-group">
<div class="dashboard-downloads-group-header">
<span class="dashboard-downloads-group-label">Search</span>
<span class="dashboard-downloads-group-count">${activeSearch.length}</span>
</div>
<div class="dashboard-bubble-container">
${activeSearch.map(name => createSearchBubbleCard(searchDownloadBubbles[name])).join('')}
</div>
</div>`;
}
// --- Discover group ---
if (activeDiscover.length > 0) {
html += `
<div class="dashboard-downloads-group">
<div class="dashboard-downloads-group-header">
<span class="dashboard-downloads-group-label">Discover</span>
<span class="dashboard-downloads-group-count">${activeDiscover.length}</span>
</div>
<div class="dashboard-bubble-container">
${activeDiscover.map(pid => createDashboardDiscoverBubble(pid)).join('')}
</div>
</div>`;
}
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 `
<div class="dashboard-discover-bubble ${isCompleted ? 'completed' : ''}"
onclick="openDiscoverDownloadModal('${playlistId}')"
title="${escapeHtml(download.name)} - Click to view">
<div class="dashboard-discover-bubble-image" style="${backgroundStyle}"></div>
<div class="dashboard-discover-bubble-overlay"></div>
<div class="dashboard-discover-bubble-content">
<div class="dashboard-discover-bubble-name">${escapeHtml(download.name)}</div>
<div class="dashboard-discover-bubble-status">${isCompleted ? 'Completed' : 'In Progress'}</div>
</div>
</div>
`;
}
function updateDbUpdaterCardInfo(stats) {
@ -20669,7 +20808,7 @@ function createArtistCardHTML(artist) {
<span>${popularityText}</span>
</div>
<div class="artist-card-actions">
<button class="watchlist-toggle-btn" data-artist-id="${artist.id}" data-artist-name="${escapeHtml(artist.name)}" onclick="toggleWatchlist(event, '${artist.id}', '${escapeHtml(artist.name)}')">
<button class="watchlist-toggle-btn" data-artist-id="${artist.id}" data-artist-name="${escapeHtml(artist.name)}" onclick="toggleWatchlist(event, '${artist.id}', '${escapeForInlineJs(artist.name)}')">
<span class="watchlist-icon">👁</span>
<span class="watchlist-text">Add to Watchlist</span>
</button>
@ -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 `
<div class="search-bubble-card ${allCompleted ? 'all-completed' : ''}"
data-artist-name="${escapeHtml(artist.name)}"
onclick="openSearchDownloadModal('${escapeHtml(artist.name)}')"
onclick="openSearchDownloadModal('${escapeForInlineJs(artist.name)}')"
title="Click to manage downloads for ${escapeHtml(artist.name)}">
<div class="search-bubble-image" style="${backgroundStyle}"></div>
<div class="search-bubble-overlay"></div>
@ -22838,7 +22979,7 @@ function createSearchBubbleCard(artistBubbleData) {
</div>
${allCompleted ? `
<div class="bulk-complete-indicator"
onclick="event.stopPropagation(); bulkCompleteSearchDownloads('${escapeHtml(artist.name)}')"
onclick="event.stopPropagation(); bulkCompleteSearchDownloads('${escapeForInlineJs(artist.name)}')"
title="Complete all downloads">
<span class="bulk-complete-icon"></span>
</div>
@ -31931,7 +32072,7 @@ async function loadGenreBrowser() {
const icon = getGenreIcon(genre.name);
const displayName = capitalizeGenre(genre.name);
html += `
<div class="discover-card genre-card-modern" onclick="openGenrePlaylist('${escapeHtml(genre.name)}')">
<div class="discover-card genre-card-modern" onclick="openGenrePlaylist('${escapeForInlineJs(genre.name)}')">
<div class="discover-card-image genre-card-image">
<div class="genre-icon-large">${icon}</div>
</div>
@ -32479,7 +32620,7 @@ async function loadGenreBrowserTabs() {
tabsHTML += `
<button class="genre-tab ${isActive ? 'active' : ''}"
data-genre="${escapeHtml(genreName)}"
onclick="switchGenreTab('${escapeHtml(genreName)}')">
onclick="switchGenreTab('${escapeForInlineJs(genreName)}')">
${icon} ${capitalizeGenre(genreName)}
</button>
`;
@ -32495,11 +32636,11 @@ async function loadGenreBrowserTabs() {
<p id="${tabId}-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;">${genre.track_count} tracks</p>
</div>
<div class="discover-section-actions">
<button class="action-button secondary" onclick="openDownloadModalForGenre('${escapeHtml(genreName)}')" title="Download missing tracks">
<button class="action-button secondary" onclick="openDownloadModalForGenre('${escapeForInlineJs(genreName)}')" title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
</button>
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startGenreSync('${escapeHtml(genreName)}')" title="Sync to media server">
<button class="action-button primary" id="${tabId}-sync-btn" onclick="startGenreSync('${escapeForInlineJs(genreName)}')" title="Sync to media server">
<span class="button-icon"></span>
<span class="button-text">Sync</span>
</button>
@ -33006,7 +33147,7 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) {
</div>
<div class="discover-section-actions">
<button class="action-button secondary"
onclick="openDownloadModalForListenBrainzPlaylist('${identifier}', '${escapeHtml(title)}')"
onclick="openDownloadModalForListenBrainzPlaylist('${identifier}', '${escapeForInlineJs(title)}')"
title="Download missing tracks">
<span class="button-icon"></span>
<span class="button-text">Download</span>
@ -34795,13 +34936,7 @@ let discoverDownloads = {}; // playlistId -> { name, type, status, virtualPlayli
function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) {
console.log(`📥 [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`);
// Check if download sidebar exists
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (!downloadSidebar) {
console.warn('⚠️ [DOWNLOAD SIDEBAR] Download sidebar element not found - user might not be on discover page');
return;
}
// Always register the download in state (needed for dashboard even when not on discover page)
discoverDownloads[playlistId] = {
name: playlistName,
type: playlistType,
@ -34812,7 +34947,17 @@ function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl =
};
console.log(`📊 [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads));
updateDiscoverDownloadBar();
// Update discover page sidebar if it exists (user is on discover page)
const downloadSidebar = document.getElementById('discover-download-sidebar');
if (downloadSidebar) {
updateDiscoverDownloadBar(); // Also saves snapshot internally
} else {
console.log(' [DOWNLOAD SIDEBAR] Sidebar not present - skipping sidebar UI update');
saveDiscoverDownloadSnapshot(); // Persist state even when sidebar is absent
}
updateDashboardDownloads();
monitorDiscoverDownload(playlistId);
}
@ -34840,6 +34985,7 @@ function monitorDiscoverDownload(playlistId) {
console.log(`✅ [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
@ -34864,6 +35010,7 @@ function monitorDiscoverDownload(playlistId) {
console.log(`✅ [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`);
discoverDownloads[playlistId].status = 'completed';
updateDiscoverDownloadBar();
updateDashboardDownloads();
clearInterval(checkInterval);
// Auto-remove completed downloads after 30 seconds
@ -34897,6 +35044,7 @@ function removeDiscoverDownload(playlistId) {
console.log(`🗑️ Removing discover download: ${playlistId}`);
delete discoverDownloads[playlistId];
updateDiscoverDownloadBar();
updateDashboardDownloads();
saveDiscoverDownloadSnapshot(); // Save state after removal
}

@ -4368,6 +4368,147 @@ body {
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
}
/* Dashboard Active Downloads */
.dashboard-downloads-group {
margin-bottom: 20px;
}
.dashboard-downloads-group:last-child {
margin-bottom: 0;
}
.dashboard-downloads-group-header {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 12px;
}
.dashboard-downloads-group-label {
font-size: 14px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
color: rgba(255, 255, 255, 0.5);
}
.dashboard-downloads-group-count {
background: rgba(29, 185, 84, 0.2);
color: #1db954;
font-size: 12px;
font-weight: 700;
padding: 2px 10px;
border-radius: 12px;
border: 1px solid rgba(29, 185, 84, 0.3);
}
.dashboard-bubble-container {
display: flex;
flex-wrap: wrap;
gap: 24px;
justify-content: center;
align-items: center;
padding: 12px;
}
/* Dashboard Discover Bubble (150px circle, matching artist/search) */
.dashboard-discover-bubble {
position: relative;
width: 150px;
height: 150px;
border-radius: 50%;
cursor: pointer;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
background: linear-gradient(135deg,
rgba(26, 26, 26, 0.95) 0%,
rgba(18, 18, 18, 0.98) 100%);
border: 2px solid rgba(255, 255, 255, 0.1);
box-shadow:
0 4px 12px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(29, 185, 84, 0.05),
inset 0 1px 0 rgba(255, 255, 255, 0.06);
overflow: hidden;
}
.dashboard-discover-bubble:hover {
transform: translateY(-3px) scale(1.05);
border-color: rgba(29, 185, 84, 0.3);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(29, 185, 84, 0.2),
0 0 15px rgba(29, 185, 84, 0.1),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.dashboard-discover-bubble.completed {
border-color: rgba(34, 197, 94, 0.4);
}
.dashboard-discover-bubble.completed:hover {
border-color: rgba(34, 197, 94, 0.6);
box-shadow:
0 8px 20px rgba(0, 0, 0, 0.5),
0 0 0 1px rgba(34, 197, 94, 0.3),
0 0 15px rgba(34, 197, 94, 0.15),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
}
.dashboard-discover-bubble-image {
position: absolute;
inset: 0;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
border-radius: 50%;
}
.dashboard-discover-bubble-overlay {
position: absolute;
inset: 0;
background: linear-gradient(135deg,
rgba(0, 0, 0, 0.3) 0%,
rgba(0, 0, 0, 0.6) 100%);
border-radius: 50%;
}
.dashboard-discover-bubble-content {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 8px;
z-index: 1;
}
.dashboard-discover-bubble-name {
font-size: 12px;
font-weight: 700;
color: #ffffff;
text-align: center;
line-height: 1.2;
max-width: 120px;
overflow: hidden;
text-overflow: ellipsis;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.dashboard-discover-bubble-status {
font-size: 10px;
font-weight: 600;
color: rgba(29, 185, 84, 0.9);
margin-top: 4px;
text-shadow: 0 1px 3px rgba(0, 0, 0, 0.8);
}
.dashboard-discover-bubble.completed .dashboard-discover-bubble-status {
color: rgba(34, 197, 94, 1);
}
/* Header Styling */
.dashboard-header {
display: flex;
@ -13251,7 +13392,8 @@ body {
#artist-hero-section #artist-detail-image {
width: 100%;
height: 100%;
height: auto;
max-width: 50vw;
}
.artist-detail-image {
@ -22756,8 +22898,13 @@ body {
}
@keyframes hydrabase-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
/* Tooltip */

Loading…
Cancel
Save