63ccf03dac
* 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
431 lines
13 KiB
Svelte
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>
|