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.
This commit is contained in:
parent
49a6c58ea8
commit
e7dfb423dc
4 changed files with 323 additions and 19 deletions
|
|
@ -354,6 +354,11 @@
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div class="dashboard-section">
|
||||||
<h3 class="section-title">Tools & Operations</h3>
|
<h3 class="section-title">Tools & Operations</h3>
|
||||||
<div class="tools-grid">
|
<div class="tools-grid">
|
||||||
|
|
|
||||||
|
|
@ -101,6 +101,10 @@
|
||||||
padding: 15px;
|
padding: 15px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#artist-hero-section #artist-detail-image {
|
||||||
|
width: 100% !important
|
||||||
|
}
|
||||||
|
|
||||||
#media-player {
|
#media-player {
|
||||||
min-height: fit-content;
|
min-height: fit-content;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -10163,6 +10163,25 @@ function escapeHtml(text) {
|
||||||
return div.innerHTML;
|
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: less-than
|
||||||
|
.replace(/>/g, '>'); // HTML: greater-than
|
||||||
|
}
|
||||||
|
|
||||||
function formatArtists(artists) {
|
function formatArtists(artists) {
|
||||||
if (!artists || !Array.isArray(artists)) {
|
if (!artists || !Array.isArray(artists)) {
|
||||||
return 'Unknown Artist';
|
return 'Unknown Artist';
|
||||||
|
|
@ -13763,6 +13782,9 @@ async function loadDashboardData() {
|
||||||
// Check for any active download processes that need rehydration
|
// Check for any active download processes that need rehydration
|
||||||
await checkForActiveProcesses();
|
await checkForActiveProcesses();
|
||||||
|
|
||||||
|
// Populate the Active Downloads dashboard section with any existing downloads
|
||||||
|
updateDashboardDownloads();
|
||||||
|
|
||||||
// Automatic wishlist processing now runs server-side
|
// Automatic wishlist processing now runs server-side
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -13791,6 +13813,123 @@ function updateDashboardStatCards(stats) {
|
||||||
// For now, we focus on the updater tool itself.
|
// 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) {
|
function updateDbUpdaterCardInfo(stats) {
|
||||||
|
|
@ -20669,7 +20808,7 @@ function createArtistCardHTML(artist) {
|
||||||
<span>${popularityText}</span>
|
<span>${popularityText}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="artist-card-actions">
|
<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-icon">👁️</span>
|
||||||
<span class="watchlist-text">Add to Watchlist</span>
|
<span class="watchlist-text">Add to Watchlist</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -22414,6 +22553,7 @@ function updateArtistDownloadsSection() {
|
||||||
}
|
}
|
||||||
downloadsUpdateTimeout = setTimeout(() => {
|
downloadsUpdateTimeout = setTimeout(() => {
|
||||||
showArtistDownloadsSection();
|
showArtistDownloadsSection();
|
||||||
|
updateDashboardDownloads();
|
||||||
}, 300); // 300ms debounce
|
}, 300); // 300ms debounce
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22713,6 +22853,7 @@ function updateSearchDownloadsSection() {
|
||||||
}
|
}
|
||||||
window.searchUpdateTimeout = setTimeout(() => {
|
window.searchUpdateTimeout = setTimeout(() => {
|
||||||
showSearchDownloadBubbles();
|
showSearchDownloadBubbles();
|
||||||
|
updateDashboardDownloads();
|
||||||
}, 300);
|
}, 300);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -22825,7 +22966,7 @@ function createSearchBubbleCard(artistBubbleData) {
|
||||||
return `
|
return `
|
||||||
<div class="search-bubble-card ${allCompleted ? 'all-completed' : ''}"
|
<div class="search-bubble-card ${allCompleted ? 'all-completed' : ''}"
|
||||||
data-artist-name="${escapeHtml(artist.name)}"
|
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)}">
|
title="Click to manage downloads for ${escapeHtml(artist.name)}">
|
||||||
<div class="search-bubble-image" style="${backgroundStyle}"></div>
|
<div class="search-bubble-image" style="${backgroundStyle}"></div>
|
||||||
<div class="search-bubble-overlay"></div>
|
<div class="search-bubble-overlay"></div>
|
||||||
|
|
@ -22838,7 +22979,7 @@ function createSearchBubbleCard(artistBubbleData) {
|
||||||
</div>
|
</div>
|
||||||
${allCompleted ? `
|
${allCompleted ? `
|
||||||
<div class="bulk-complete-indicator"
|
<div class="bulk-complete-indicator"
|
||||||
onclick="event.stopPropagation(); bulkCompleteSearchDownloads('${escapeHtml(artist.name)}')"
|
onclick="event.stopPropagation(); bulkCompleteSearchDownloads('${escapeForInlineJs(artist.name)}')"
|
||||||
title="Complete all downloads">
|
title="Complete all downloads">
|
||||||
<span class="bulk-complete-icon">✅</span>
|
<span class="bulk-complete-icon">✅</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -31931,7 +32072,7 @@ async function loadGenreBrowser() {
|
||||||
const icon = getGenreIcon(genre.name);
|
const icon = getGenreIcon(genre.name);
|
||||||
const displayName = capitalizeGenre(genre.name);
|
const displayName = capitalizeGenre(genre.name);
|
||||||
html += `
|
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="discover-card-image genre-card-image">
|
||||||
<div class="genre-icon-large">${icon}</div>
|
<div class="genre-icon-large">${icon}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -32479,7 +32620,7 @@ async function loadGenreBrowserTabs() {
|
||||||
tabsHTML += `
|
tabsHTML += `
|
||||||
<button class="genre-tab ${isActive ? 'active' : ''}"
|
<button class="genre-tab ${isActive ? 'active' : ''}"
|
||||||
data-genre="${escapeHtml(genreName)}"
|
data-genre="${escapeHtml(genreName)}"
|
||||||
onclick="switchGenreTab('${escapeHtml(genreName)}')">
|
onclick="switchGenreTab('${escapeForInlineJs(genreName)}')">
|
||||||
${icon} ${capitalizeGenre(genreName)}
|
${icon} ${capitalizeGenre(genreName)}
|
||||||
</button>
|
</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>
|
<p id="${tabId}-subtitle" style="margin: 4px 0 0 0; color: #999; font-size: 13px;">${genre.track_count} tracks</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-section-actions">
|
<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-icon">↓</span>
|
||||||
<span class="button-text">Download</span>
|
<span class="button-text">Download</span>
|
||||||
</button>
|
</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-icon">⟳</span>
|
||||||
<span class="button-text">Sync</span>
|
<span class="button-text">Sync</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
@ -33006,7 +33147,7 @@ function buildListenBrainzPlaylistsHtml(playlists, tabId) {
|
||||||
</div>
|
</div>
|
||||||
<div class="discover-section-actions">
|
<div class="discover-section-actions">
|
||||||
<button class="action-button secondary"
|
<button class="action-button secondary"
|
||||||
onclick="openDownloadModalForListenBrainzPlaylist('${identifier}', '${escapeHtml(title)}')"
|
onclick="openDownloadModalForListenBrainzPlaylist('${identifier}', '${escapeForInlineJs(title)}')"
|
||||||
title="Download missing tracks">
|
title="Download missing tracks">
|
||||||
<span class="button-icon">↓</span>
|
<span class="button-icon">↓</span>
|
||||||
<span class="button-text">Download</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) {
|
function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl = null) {
|
||||||
console.log(`📥 [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`);
|
console.log(`📥 [DOWNLOAD SIDEBAR] Adding discover download: ${playlistName} (${playlistId}) type: ${playlistType}, image: ${imageUrl}`);
|
||||||
|
|
||||||
// Check if download sidebar exists
|
// Always register the download in state (needed for dashboard even when not on discover page)
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
discoverDownloads[playlistId] = {
|
discoverDownloads[playlistId] = {
|
||||||
name: playlistName,
|
name: playlistName,
|
||||||
type: playlistType,
|
type: playlistType,
|
||||||
|
|
@ -34812,7 +34947,17 @@ function addDiscoverDownload(playlistId, playlistName, playlistType, imageUrl =
|
||||||
};
|
};
|
||||||
|
|
||||||
console.log(`📊 [DOWNLOAD SIDEBAR] Active downloads:`, Object.keys(discoverDownloads));
|
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);
|
monitorDiscoverDownload(playlistId);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -34840,6 +34985,7 @@ function monitorDiscoverDownload(playlistId) {
|
||||||
console.log(`✅ [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`);
|
console.log(`✅ [DOWNLOAD BAR] Process completed: ${discoverDownloads[playlistId].name}`);
|
||||||
discoverDownloads[playlistId].status = 'completed';
|
discoverDownloads[playlistId].status = 'completed';
|
||||||
updateDiscoverDownloadBar();
|
updateDiscoverDownloadBar();
|
||||||
|
updateDashboardDownloads();
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
|
|
||||||
// Auto-remove completed downloads after 30 seconds
|
// Auto-remove completed downloads after 30 seconds
|
||||||
|
|
@ -34864,6 +35010,7 @@ function monitorDiscoverDownload(playlistId) {
|
||||||
console.log(`✅ [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`);
|
console.log(`✅ [DOWNLOAD BAR] Sync completed: ${discoverDownloads[playlistId].name}`);
|
||||||
discoverDownloads[playlistId].status = 'completed';
|
discoverDownloads[playlistId].status = 'completed';
|
||||||
updateDiscoverDownloadBar();
|
updateDiscoverDownloadBar();
|
||||||
|
updateDashboardDownloads();
|
||||||
clearInterval(checkInterval);
|
clearInterval(checkInterval);
|
||||||
|
|
||||||
// Auto-remove completed downloads after 30 seconds
|
// Auto-remove completed downloads after 30 seconds
|
||||||
|
|
@ -34897,6 +35044,7 @@ function removeDiscoverDownload(playlistId) {
|
||||||
console.log(`🗑️ Removing discover download: ${playlistId}`);
|
console.log(`🗑️ Removing discover download: ${playlistId}`);
|
||||||
delete discoverDownloads[playlistId];
|
delete discoverDownloads[playlistId];
|
||||||
updateDiscoverDownloadBar();
|
updateDiscoverDownloadBar();
|
||||||
|
updateDashboardDownloads();
|
||||||
saveDiscoverDownloadSnapshot(); // Save state after removal
|
saveDiscoverDownloadSnapshot(); // Save state after removal
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -4368,6 +4368,147 @@ body {
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
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 */
|
/* Header Styling */
|
||||||
.dashboard-header {
|
.dashboard-header {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
|
@ -13251,7 +13392,8 @@ body {
|
||||||
|
|
||||||
#artist-hero-section #artist-detail-image {
|
#artist-hero-section #artist-detail-image {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: auto;
|
||||||
|
max-width: 50vw;
|
||||||
}
|
}
|
||||||
|
|
||||||
.artist-detail-image {
|
.artist-detail-image {
|
||||||
|
|
@ -22756,8 +22898,13 @@ body {
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes hydrabase-spin {
|
@keyframes hydrabase-spin {
|
||||||
from { transform: rotate(0deg); }
|
from {
|
||||||
to { transform: rotate(360deg); }
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Tooltip */
|
/* Tooltip */
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue