Files
musicseerr/frontend/src/routes/genre/+page.svelte
T
Arno 63ccf03dac refactor: Prototype of tanstack-query (#34)
* move getApiUrl to api folder

* adjust imports

* tanstack-query example with homeData

* small adjustments

* fix key collision

* new MusicSource persistent mechanism example

* add error handling & set sveltekit to SPA mode

* remove unnecessary ssr test
2026-04-11 13:46:07 +01:00

431 lines
13 KiB
Svelte

<script lang="ts">
import { getApiUrl } from '$lib/api/api-utils';
import { page } from '$app/state';
import { onMount, onDestroy } from 'svelte';
import { beforeNavigate } from '$app/navigation';
import GenreArtistCard from '$lib/components/GenreArtistCard.svelte';
import GenreAlbumCard from '$lib/components/GenreAlbumCard.svelte';
import { CACHE_KEYS, CACHE_TTL } from '$lib/constants';
import type { GenreDetailResponse } from '$lib/types';
import { createAbortable } from '$lib/utils/abortController';
import { albumHrefOrNull, artistHrefOrNull } from '$lib/utils/entityRoutes';
import { isAbortError } from '$lib/utils/errorHandling';
import { api } from '$lib/api/client';
import { createLocalStorageCache } from '$lib/utils/localStorageCache';
import { ArrowLeft, BookOpen, Music2, CircleAlert, Mic, Disc3, ChevronDown } from 'lucide-svelte';
let genreName = $derived(page.url.searchParams.get('name') || '');
let genreData: GenreDetailResponse | null = $state(null);
let loading = $state(true);
let error = $state('');
let heroArtistMbid: string | null = $state(null);
let heroImageLoaded = $state(false);
const genreRequestAbortable = createAbortable();
let lastLoadedGenre = '';
let artistOffset = $state(0);
let albumOffset = $state(0);
let loadingMoreArtists = $state(false);
let loadingMoreAlbums = $state(false);
const PAGE_SIZE = 50;
const genreDetailCache = createLocalStorageCache<GenreDetailResponse>(
CACHE_KEYS.GENRE_DETAIL_CACHE,
CACHE_TTL.GENRE_DETAIL,
{ maxEntries: 60 }
);
function getGenreCacheSuffix(): string {
return encodeURIComponent(genreName.trim().toLowerCase());
}
function persistGenreCache() {
if (!genreData || !genreName) return;
genreDetailCache.set(genreData, getGenreCacheSuffix());
}
async function loadHeroArtist() {
if (!genreName) return;
heroArtistMbid = null;
heroImageLoaded = false;
try {
const data = await api.get<{ artist_mbid: string }>(
`/api/v1/home/genre-artist/${encodeURIComponent(genreName)}`,
{
signal: genreRequestAbortable.signal
}
);
heroArtistMbid = data.artist_mbid;
} catch (e) {
if (isAbortError(e)) return;
}
}
async function loadGenreData() {
if (!genreName) {
error = 'No genre specified';
loading = false;
return;
}
const cacheSuffix = getGenreCacheSuffix();
const cachedGenreData = genreDetailCache.get(cacheSuffix);
const hasCachedGenreData = !!cachedGenreData?.data;
const shouldRefresh = !cachedGenreData || genreDetailCache.isStale(cachedGenreData.timestamp);
if (hasCachedGenreData) {
genreData = cachedGenreData.data;
loading = false;
}
if (!shouldRefresh) {
error = '';
return;
}
loading = !hasCachedGenreData;
error = '';
artistOffset = 0;
albumOffset = 0;
try {
const data: GenreDetailResponse = await api.get(
`/api/v1/home/genre/${encodeURIComponent(genreName)}?limit=${PAGE_SIZE}`,
{ signal: genreRequestAbortable.signal }
);
genreData = data;
genreDetailCache.set(data, cacheSuffix);
} catch (e) {
if (isAbortError(e)) return;
if (!hasCachedGenreData) {
error = "Couldn't load this genre";
}
} finally {
if (!hasCachedGenreData) {
loading = false;
}
}
}
async function loadMoreArtists() {
if (!genreData || loadingMoreArtists || !genreData.popular?.has_more_artists) return;
loadingMoreArtists = true;
artistOffset += PAGE_SIZE;
try {
const data: GenreDetailResponse = await api.get(
`/api/v1/home/genre/${encodeURIComponent(genreName)}?limit=${PAGE_SIZE}&artist_offset=${artistOffset}`,
{ signal: genreRequestAbortable.signal }
);
if (genreData.popular && data.popular) {
genreData.popular.artists = [...genreData.popular.artists, ...data.popular.artists];
genreData.popular.has_more_artists = data.popular.has_more_artists;
persistGenreCache();
}
} catch (e) {
if (isAbortError(e)) return;
artistOffset -= PAGE_SIZE;
} finally {
loadingMoreArtists = false;
}
}
async function loadMoreAlbums() {
if (!genreData || loadingMoreAlbums || !genreData.popular?.has_more_albums) return;
loadingMoreAlbums = true;
albumOffset += PAGE_SIZE;
try {
const data: GenreDetailResponse = await api.get(
`/api/v1/home/genre/${encodeURIComponent(genreName)}?limit=${PAGE_SIZE}&album_offset=${albumOffset}`,
{ signal: genreRequestAbortable.signal }
);
if (genreData.popular && data.popular) {
genreData.popular.albums = [...genreData.popular.albums, ...data.popular.albums];
genreData.popular.has_more_albums = data.popular.has_more_albums;
persistGenreCache();
}
} catch (e) {
if (isAbortError(e)) return;
albumOffset -= PAGE_SIZE;
} finally {
loadingMoreAlbums = false;
}
}
function loadData() {
genreRequestAbortable.reset();
lastLoadedGenre = genreName;
void loadGenreData();
void loadHeroArtist();
}
function cleanup() {
genreRequestAbortable.abort();
}
onMount(() => {
if (genreName) loadData();
});
onDestroy(cleanup);
beforeNavigate(cleanup);
$effect(() => {
if (genreName && genreName !== lastLoadedGenre) loadData();
});
const hasLibraryContent = $derived.by(() => {
const data = genreData;
return (data?.library?.artists?.length ?? 0) > 0 || (data?.library?.albums?.length ?? 0) > 0;
});
</script>
<svelte:head>
<title>{genreName ? `${genreName}` : 'Genre'} - Musicseerr</title>
</svelte:head>
<div class="min-h-screen bg-base-100 relative overflow-hidden">
{#if heroArtistMbid}
<div
class="absolute inset-x-0 top-0 h-96 overflow-hidden pointer-events-none"
style="z-index: 0;"
>
<img
src={getApiUrl(`/api/v1/covers/artist/${heroArtistMbid}?size=500`)}
alt=""
class="w-full h-full object-cover object-top transition-opacity duration-700 {heroImageLoaded
? 'opacity-20'
: 'opacity-0'}"
onload={() => (heroImageLoaded = true)}
/>
<div
class="absolute inset-0 bg-linear-to-b from-base-100/30 via-base-100/70 to-base-100"
></div>
</div>
{/if}
<div class="container mx-auto p-4 max-w-7xl relative" style="z-index: 1;">
<header class="mb-10 pt-2">
<a href="/" class="btn btn-ghost btn-sm gap-2 mb-6 -ml-2 opacity-70 hover:opacity-100">
<ArrowLeft class="w-4 h-4" />
Back
</a>
<div class="flex items-center gap-5">
<div
class="w-20 h-20 rounded-2xl bg-linear-to-br from-primary/30 to-secondary/30 flex items-center justify-center shrink-0"
>
<Music2 class="h-10 w-10 text-primary" />
</div>
<div>
<h1 class="text-4xl sm:text-5xl font-bold capitalize tracking-tight">
{genreName || 'Genre'}
</h1>
{#if genreData}
<p class="text-base-content/50 mt-2 text-sm sm:text-base">
{#if hasLibraryContent}
{genreData.library?.artist_count ?? 0} artists · {genreData.library?.album_count ??
0} albums in your library
{:else}
Explore popular {genreName} music
{/if}
</p>
{/if}
</div>
</div>
</header>
{#if loading}
<section class="mb-12" aria-label="Loading">
<div class="flex items-center gap-3 mb-6">
<div class="skeleton w-10 h-10 rounded-xl"></div>
<div>
<div class="skeleton h-6 w-48 mb-2"></div>
<div class="skeleton h-4 w-32"></div>
</div>
</div>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{#each Array(12) as _, i (`genre-artist-skeleton-${i}`)}
<div class="card bg-base-200/50">
<div class="skeleton aspect-square rounded-t-2xl"></div>
<div class="p-3">
<div class="skeleton h-4 w-3/4 mb-2"></div>
<div class="skeleton h-3 w-1/2"></div>
</div>
</div>
{/each}
</div>
</section>
<section class="mb-12" aria-label="Loading">
<div class="flex items-center gap-3 mb-6">
<div class="skeleton w-10 h-10 rounded-xl"></div>
<div>
<div class="skeleton h-6 w-40 mb-2"></div>
<div class="skeleton h-4 w-28"></div>
</div>
</div>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{#each Array(6) as _, i (`genre-album-skeleton-${i}`)}
<div class="card bg-base-200/50">
<div class="skeleton aspect-square rounded-t-2xl"></div>
<div class="p-3">
<div class="skeleton h-4 w-3/4 mb-2"></div>
<div class="skeleton h-3 w-1/2"></div>
</div>
</div>
{/each}
</div>
</section>
{:else if error}
<div class="flex flex-col items-center justify-center py-24">
<CircleAlert class="h-12 w-12 text-base-content/40 mb-4" strokeWidth={1.5} />
<p class="text-base-content/70 text-lg">{error}</p>
<button class="btn btn-primary mt-6" onclick={loadData}>Try Again</button>
</div>
{:else if genreData}
{#if hasLibraryContent}
<section class="mb-12" aria-label="From Your Library">
<div class="flex items-center gap-3 mb-6">
<div
class="w-10 h-10 rounded-xl bg-success/20 flex items-center justify-center text-success"
>
<BookOpen class="w-5 h-5" />
</div>
<div>
<h2 class="text-2xl font-bold">From Your Library</h2>
<p class="text-sm text-base-content/50">
{genreData.library?.artist_count ?? 0} artists · {genreData.library?.album_count ??
0} albums
</p>
</div>
</div>
{#if (genreData.library?.artists?.length ?? 0) > 0}
<h3 class="text-lg font-semibold mb-4 text-base-content/70">Artists</h3>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4 mb-8"
>
{#each genreData.library?.artists ?? [] as artist (artist.mbid || artist.name)}
<GenreArtistCard
{artist}
showLibraryBadge={true}
href={artistHrefOrNull(artist.mbid)}
/>
{/each}
</div>
{/if}
{#if (genreData.library?.albums?.length ?? 0) > 0}
<h3 class="text-lg font-semibold mb-4 text-base-content/70">Albums</h3>
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{#each genreData.library?.albums ?? [] as album (album.mbid || album.name)}
<GenreAlbumCard
{album}
showLibraryBadge={true}
href={albumHrefOrNull(album.mbid)}
/>
{/each}
</div>
{/if}
</section>
<div class="divider my-8 opacity-30"></div>
{/if}
<section class="mb-12" aria-label="Popular Artists">
<div class="flex items-center gap-3 mb-6">
<div
class="w-10 h-10 rounded-xl bg-primary/20 flex items-center justify-center text-primary"
>
<Mic class="w-5 h-5" />
</div>
<div>
<h2 class="text-2xl font-bold">Popular Artists</h2>
<p class="text-sm text-base-content/50">Top {genreName} artists</p>
</div>
</div>
{#if (genreData.popular?.artists?.length ?? 0) === 0}
<div class="flex flex-col items-center justify-center py-16">
<Mic class="h-10 w-10 text-base-content/30 mb-4" strokeWidth={1.5} />
<p class="text-base-content/50">No artists found for this genre</p>
</div>
{:else}
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{#each genreData.popular?.artists ?? [] as artist (artist.mbid || artist.name)}
<GenreArtistCard {artist} href={artistHrefOrNull(artist.mbid)} />
{/each}
</div>
{#if genreData.popular?.has_more_artists}
<div class="flex justify-center mt-8">
<button
class="btn btn-outline btn-wide gap-2"
onclick={loadMoreArtists}
disabled={loadingMoreArtists}
>
{#if loadingMoreArtists}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
View More Artists
</button>
</div>
{/if}
{/if}
</section>
<section class="mb-12" aria-label="Popular Albums">
<div class="flex items-center gap-3 mb-6">
<div
class="w-10 h-10 rounded-xl bg-secondary/20 flex items-center justify-center text-secondary"
>
<Disc3 class="w-5 h-5" />
</div>
<div>
<h2 class="text-2xl font-bold">Popular Albums</h2>
<p class="text-sm text-base-content/50">Top {genreName} albums</p>
</div>
</div>
{#if (genreData.popular?.albums?.length ?? 0) === 0}
<div class="flex flex-col items-center justify-center py-16">
<Disc3 class="h-10 w-10 text-base-content/30 mb-4" strokeWidth={1.5} />
<p class="text-base-content/50">No albums found for this genre</p>
</div>
{:else}
<div
class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4"
>
{#each genreData.popular?.albums ?? [] as album (album.mbid || album.name)}
<GenreAlbumCard {album} href={albumHrefOrNull(album.mbid)} />
{/each}
</div>
{#if genreData.popular?.has_more_albums}
<div class="flex justify-center mt-8">
<button
class="btn btn-outline btn-wide gap-2"
onclick={loadMoreAlbums}
disabled={loadingMoreAlbums}
>
{#if loadingMoreAlbums}
<span class="loading loading-spinner loading-sm"></span>
{:else}
<ChevronDown class="w-4 h-4" />
{/if}
View More Albums
</button>
</div>
{/if}
{/if}
</section>
{/if}
</div>
</div>