Initial public release
This commit is contained in:
@@ -0,0 +1,430 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
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 = $state('');
|
||||
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());
|
||||
}
|
||||
|
||||
$effect(() => {
|
||||
genreName = $page.url.searchParams.get('name') || '';
|
||||
});
|
||||
|
||||
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="/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-gradient-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-gradient-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 _}
|
||||
<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 _}
|
||||
<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>
|
||||
Reference in New Issue
Block a user