fix(ux): favorite button click + home loading overlay
TrackRow: - Convert favorite indicator div to interactive button - Add stopPropagation to prevent row click (play) when clicking favorite - Add hover state and proper button styling HomeView: - Add loading overlay that waits for at least 2 sections to load - Overlay fades out smoothly when content is ready - Prevents jarring "sections appearing and reordering" effect
This commit is contained in:
1 parent
0ca7d50615
commit
c529a28c6e
2 files changed
+126
-13
No files matched your search
@@ -97,14 +97,22 @@
|
|||||||
<div class="track-quality">{quality}</div>
|
<div class="track-quality">{quality}</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<!-- Favorite Indicator -->
|
<!-- Favorite Button -->
|
||||||
<div class="favorite-indicator" class:is-favorite={isFavorite}>
|
<button
|
||||||
|
class="favorite-btn"
|
||||||
|
class:is-favorite={isFavorite}
|
||||||
|
onclick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
menuActions?.onAddFavorite?.();
|
||||||
|
}}
|
||||||
|
title={isFavorite ? 'Remove from favorites' : 'Add to favorites'}
|
||||||
|
>
|
||||||
{#if isFavorite}
|
{#if isFavorite}
|
||||||
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
|
<Heart size={14} fill="var(--accent-primary)" color="var(--accent-primary)" />
|
||||||
{:else}
|
{:else}
|
||||||
<Heart size={14} color="var(--text-muted)" />
|
<Heart size={14} color="var(--text-muted)" />
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</button>
|
||||||
|
|
||||||
<!-- Download Indicator -->
|
<!-- Download Indicator -->
|
||||||
{#if !hideDownload}
|
{#if !hideDownload}
|
||||||
@@ -251,24 +259,35 @@
|
|||||||
text-align: right;
|
text-align: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorite-indicator {
|
.favorite-btn {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
width: 24px;
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
cursor: pointer;
|
||||||
opacity: 0.3;
|
opacity: 0.3;
|
||||||
transition: opacity 150ms ease;
|
transition: opacity 150ms ease, background-color 150ms ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.favorite-indicator.is-favorite {
|
.favorite-btn.is-favorite {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-row:hover .favorite-indicator {
|
.favorite-btn:hover {
|
||||||
|
opacity: 1;
|
||||||
|
background-color: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-row:hover .favorite-btn {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
|
|
||||||
.track-row:hover .favorite-indicator.is-favorite {
|
.track-row:hover .favorite-btn.is-favorite,
|
||||||
|
.track-row:hover .favorite-btn:hover {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -83,6 +83,7 @@
|
|||||||
|
|
||||||
// Loading states for progressive render
|
// Loading states for progressive render
|
||||||
let isInitializing = $state(true);
|
let isInitializing = $state(true);
|
||||||
|
let isOverlayVisible = $state(true); // Overlay that fades out when ready
|
||||||
let error = $state<string | null>(null);
|
let error = $state<string | null>(null);
|
||||||
let loadingNewReleases = $state(true);
|
let loadingNewReleases = $state(true);
|
||||||
let loadingPressAwards = $state(true);
|
let loadingPressAwards = $state(true);
|
||||||
@@ -94,6 +95,10 @@
|
|||||||
let loadingTopArtists = $state(true);
|
let loadingTopArtists = $state(true);
|
||||||
let loadingFavoriteAlbums = $state(true);
|
let loadingFavoriteAlbums = $state(true);
|
||||||
|
|
||||||
|
// Track when we have enough content to show
|
||||||
|
let sectionsReady = $state(0);
|
||||||
|
const MIN_SECTIONS_BEFORE_SHOW = 2; // Wait for at least 2 sections
|
||||||
|
|
||||||
// Featured albums (from Qobuz editorial)
|
// Featured albums (from Qobuz editorial)
|
||||||
let newReleases = $state<AlbumCardData[]>([]);
|
let newReleases = $state<AlbumCardData[]>([]);
|
||||||
let pressAwards = $state<AlbumCardData[]>([]);
|
let pressAwards = $state<AlbumCardData[]>([]);
|
||||||
@@ -121,6 +126,17 @@
|
|||||||
|| favoriteAlbums.length > 0
|
|| favoriteAlbums.length > 0
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Mark a section as ready and check if we can hide overlay
|
||||||
|
function markSectionReady() {
|
||||||
|
sectionsReady++;
|
||||||
|
if (sectionsReady >= MIN_SECTIONS_BEFORE_SHOW && isOverlayVisible) {
|
||||||
|
// Small delay to ensure DOM is ready, then fade out
|
||||||
|
setTimeout(() => {
|
||||||
|
isOverlayVisible = false;
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
// Subscribe to home settings changes
|
// Subscribe to home settings changes
|
||||||
const unsubscribe = subscribeHomeSettings(() => {
|
const unsubscribe = subscribeHomeSettings(() => {
|
||||||
@@ -256,6 +272,8 @@
|
|||||||
|
|
||||||
async function loadHome() {
|
async function loadHome() {
|
||||||
isInitializing = true;
|
isInitializing = true;
|
||||||
|
isOverlayVisible = true;
|
||||||
|
sectionsReady = 0;
|
||||||
error = null;
|
error = null;
|
||||||
loadingNewReleases = true;
|
loadingNewReleases = true;
|
||||||
loadingPressAwards = true;
|
loadingPressAwards = true;
|
||||||
@@ -274,8 +292,7 @@
|
|||||||
fetchFeaturedAlbums('new-releases', LIMITS.featuredAlbums).then(albums => {
|
fetchFeaturedAlbums('new-releases', LIMITS.featuredAlbums).then(albums => {
|
||||||
newReleases = albums;
|
newReleases = albums;
|
||||||
loadingNewReleases = false;
|
loadingNewReleases = false;
|
||||||
// Hide initializing as soon as we have some content
|
if (albums.length > 0) markSectionReady();
|
||||||
if (albums.length > 0) isInitializing = false;
|
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
loadingNewReleases = false;
|
loadingNewReleases = false;
|
||||||
@@ -285,6 +302,7 @@
|
|||||||
fetchFeaturedAlbums('press-awards', LIMITS.featuredAlbums).then(albums => {
|
fetchFeaturedAlbums('press-awards', LIMITS.featuredAlbums).then(albums => {
|
||||||
pressAwards = albums;
|
pressAwards = albums;
|
||||||
loadingPressAwards = false;
|
loadingPressAwards = false;
|
||||||
|
if (albums.length > 0) markSectionReady();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
loadingPressAwards = false;
|
loadingPressAwards = false;
|
||||||
@@ -294,6 +312,7 @@
|
|||||||
fetchFeaturedAlbums('most-streamed', LIMITS.featuredAlbums).then(albums => {
|
fetchFeaturedAlbums('most-streamed', LIMITS.featuredAlbums).then(albums => {
|
||||||
mostStreamed = albums;
|
mostStreamed = albums;
|
||||||
loadingMostStreamed = false;
|
loadingMostStreamed = false;
|
||||||
|
if (albums.length > 0) markSectionReady();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
loadingMostStreamed = false;
|
loadingMostStreamed = false;
|
||||||
@@ -303,6 +322,7 @@
|
|||||||
fetchFeaturedAlbums('qobuzissimes', LIMITS.featuredAlbums).then(albums => {
|
fetchFeaturedAlbums('qobuzissimes', LIMITS.featuredAlbums).then(albums => {
|
||||||
qobuzissimes = albums;
|
qobuzissimes = albums;
|
||||||
loadingQobuzissimes = false;
|
loadingQobuzissimes = false;
|
||||||
|
if (albums.length > 0) markSectionReady();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
loadingQobuzissimes = false;
|
loadingQobuzissimes = false;
|
||||||
@@ -312,6 +332,7 @@
|
|||||||
fetchFeaturedAlbums('editor-picks', LIMITS.featuredAlbums).then(albums => {
|
fetchFeaturedAlbums('editor-picks', LIMITS.featuredAlbums).then(albums => {
|
||||||
editorPicks = albums;
|
editorPicks = albums;
|
||||||
loadingEditorPicks = false;
|
loadingEditorPicks = false;
|
||||||
|
if (albums.length > 0) markSectionReady();
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
loadingEditorPicks = false;
|
loadingEditorPicks = false;
|
||||||
@@ -326,13 +347,14 @@
|
|||||||
limitFavorites: Math.max(LIMITS.favoriteAlbums, LIMITS.favoriteTracks)
|
limitFavorites: Math.max(LIMITS.favoriteAlbums, LIMITS.favoriteTracks)
|
||||||
});
|
});
|
||||||
|
|
||||||
// Seeds are ready, hide initializing state - show sections with loading
|
// Seeds are ready, can start showing content
|
||||||
isInitializing = false;
|
isInitializing = false;
|
||||||
|
|
||||||
// Load each section in parallel for progressive rendering
|
// Load each section in parallel for progressive rendering
|
||||||
const continueTracksPromise = fetchTracks(seeds.continueListeningTrackIds).then(tracks => {
|
const continueTracksPromise = fetchTracks(seeds.continueListeningTrackIds).then(tracks => {
|
||||||
continueTracks = tracks;
|
continueTracks = tracks;
|
||||||
loadingContinueTracks = false;
|
loadingContinueTracks = false;
|
||||||
|
if (tracks.length > 0) markSectionReady();
|
||||||
return tracks;
|
return tracks;
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -347,6 +369,7 @@
|
|||||||
fetchAlbums(recentAlbumIds.slice(0, LIMITS.recentAlbums)).then(albums => {
|
fetchAlbums(recentAlbumIds.slice(0, LIMITS.recentAlbums)).then(albums => {
|
||||||
recentAlbums = albums;
|
recentAlbums = albums;
|
||||||
loadingRecentAlbums = false;
|
loadingRecentAlbums = false;
|
||||||
|
if (albums.length > 0) markSectionReady();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Favorite albums
|
// Favorite albums
|
||||||
@@ -358,6 +381,7 @@
|
|||||||
const albums = await fetchAlbums(favoriteAlbumIds.slice(0, LIMITS.favoriteAlbums));
|
const albums = await fetchAlbums(favoriteAlbumIds.slice(0, LIMITS.favoriteAlbums));
|
||||||
favoriteAlbums = albums;
|
favoriteAlbums = albums;
|
||||||
loadingFavoriteAlbums = false;
|
loadingFavoriteAlbums = false;
|
||||||
|
if (albums.length > 0) markSectionReady();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Top artists
|
// Top artists
|
||||||
@@ -369,12 +393,14 @@
|
|||||||
fetchArtists(artistSeeds.slice(0, LIMITS.topArtists)).then(artists => {
|
fetchArtists(artistSeeds.slice(0, LIMITS.topArtists)).then(artists => {
|
||||||
topArtists = artists;
|
topArtists = artists;
|
||||||
loadingTopArtists = false;
|
loadingTopArtists = false;
|
||||||
|
if (artists.length > 0) markSectionReady();
|
||||||
});
|
});
|
||||||
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to load home data:', err);
|
console.error('Failed to load home data:', err);
|
||||||
error = String(err);
|
error = String(err);
|
||||||
isInitializing = false;
|
isInitializing = false;
|
||||||
|
isOverlayVisible = false;
|
||||||
loadingRecentAlbums = false;
|
loadingRecentAlbums = false;
|
||||||
loadingContinueTracks = false;
|
loadingContinueTracks = false;
|
||||||
loadingTopArtists = false;
|
loadingTopArtists = false;
|
||||||
@@ -384,6 +410,19 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="home-view">
|
<div class="home-view">
|
||||||
|
<!-- Loading Overlay - fades out when content is ready -->
|
||||||
|
{#if isOverlayVisible}
|
||||||
|
<div class="loading-overlay" class:fade-out={!isInitializing && sectionsReady >= MIN_SECTIONS_BEFORE_SHOW}>
|
||||||
|
<div class="loading-content">
|
||||||
|
<div class="loading-icon">
|
||||||
|
<Loader2 size={36} class="spinner" />
|
||||||
|
</div>
|
||||||
|
<h2>Loading your home</h2>
|
||||||
|
<p>Building recommendations from your listening history.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<!-- Header with greeting and settings -->
|
<!-- Header with greeting and settings -->
|
||||||
<div class="home-header">
|
<div class="home-header">
|
||||||
{#if homeSettings.greeting.enabled}
|
{#if homeSettings.greeting.enabled}
|
||||||
@@ -396,7 +435,7 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if isInitializing}
|
{#if isInitializing && !isOverlayVisible}
|
||||||
<div class="home-state">
|
<div class="home-state">
|
||||||
<div class="state-icon loading">
|
<div class="state-icon loading">
|
||||||
<Loader2 size={36} class="spinner" />
|
<Loader2 size={36} class="spinner" />
|
||||||
@@ -604,6 +643,61 @@
|
|||||||
.home-view {
|
.home-view {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding-bottom: 24px;
|
padding-bottom: 24px;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading Overlay */
|
||||||
|
.loading-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 10;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: opacity 300ms ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-overlay.fade-out {
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: linear-gradient(135deg, var(--accent-primary) 0%, var(--accent-secondary, #6366f1) 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content h2 {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-content p {
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin: 0;
|
||||||
|
max-width: 360px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-icon :global(.spinner) {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
}
|
}
|
||||||
|
|
||||||
.home-header {
|
.home-header {
|
||||||
|
|||||||
Reference in new issue
Block a user