feat(S03/T02): Add similar artists horizontal scroll section with click…

- frontend/src/components/explore-artist-details/explore-artist-details.ts
This commit is contained in:
2026-03-24 13:43:22 -04:00
parent 6ee16c7a87
commit bb271e86f5
@@ -5,11 +5,13 @@ import {
LookupArtist, LookupArtist,
BrowseReleaseGroups, BrowseReleaseGroups,
TopRecordingsForArtist, TopRecordingsForArtist,
SimilarArtists,
} from '@go/explore/Service'; } from '@go/explore/Service';
import type { import type {
MBArtist, MBArtist,
MBReleaseGroup, MBReleaseGroup,
LBTopRecording, LBTopRecording,
LBSimilarArtist,
} from '@go/explore/Service'; } from '@go/explore/Service';
import '@awesome.me/webawesome/dist/components/icon/icon.js'; import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -67,6 +69,8 @@ export class ExploreArtistDetails extends LitElement {
@state() private errorArtist = ''; @state() private errorArtist = '';
@state() private errorTracks = ''; @state() private errorTracks = '';
@state() private errorReleases = ''; @state() private errorReleases = '';
@state() private similarArtists: LBSimilarArtist[] = [];
@state() private loadingSimilar = true;
/* ── Styles ── */ /* ── Styles ── */
@@ -370,6 +374,70 @@ export class ExploreArtistDetails extends LitElement {
color: var(--yj-text-tertiary, #888); color: var(--yj-text-tertiary, #888);
font-size: var(--yj-text-xs); font-size: var(--yj-text-xs);
} }
/* ── Similar artists ── */
.horizontal-row {
display: flex;
gap: 12px;
overflow-x: auto;
padding-bottom: 4px;
scrollbar-width: none;
}
.horizontal-row::-webkit-scrollbar {
display: none;
}
.similar-artist-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 8px;
padding: 10px;
border-radius: 8px;
cursor: pointer;
min-width: 100px;
max-width: 120px;
flex-shrink: 0;
text-align: center;
transition: background 0.15s ease;
}
.similar-artist-card:hover {
background: var(
--yj-bg-overlay,
rgba(255, 255, 255, 0.06)
);
}
.similar-artist-card:active {
transform: scale(0.97);
}
.similar-avatar {
width: 48px;
height: 48px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
color: #fff;
font-weight: 600;
font-size: 20px;
text-transform: uppercase;
user-select: none;
flex-shrink: 0;
}
.similar-name {
font-weight: 500;
color: var(--yj-text-primary, #fff);
font-size: var(--yj-text-sm);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
width: 100%;
}
`, `,
]; ];
@@ -387,23 +455,27 @@ export class ExploreArtistDetails extends LitElement {
private async loadAllData() { private async loadAllData() {
const mbid = this.artistMBID; const mbid = this.artistMBID;
console.log( console.log(
`[explore-artist] loading artist page: "${this.artistName}" (${mbid})`, `[explore-artist] loading: "${this.artistName}" (${mbid})`,
); );
// Fire all three requests in parallel — each section is independent. // Fire all four requests in parallel — each section is independent.
const [artistResult, tracksResult, releasesResult] = const [artistResult, tracksResult, releasesResult, similarResult] =
await Promise.allSettled([ await Promise.allSettled([
this.fetchArtist(mbid), this.fetchArtist(mbid),
this.fetchTopTracks(mbid), this.fetchTopTracks(mbid),
this.fetchReleaseGroups(mbid), this.fetchReleaseGroups(mbid),
this.fetchSimilarArtists(mbid),
]); ]);
const summary = [ const summary = [
`artist=${artistResult.status}`, `artist=${artistResult.status}`,
`tracks=${tracksResult.status}`, `tracks=${tracksResult.status}`,
`releases=${releasesResult.status}`, `releases=${releasesResult.status}`,
`similar=${similarResult.status}`,
].join(', '); ].join(', ');
console.log(`[explore-artist] load complete: ${summary}`); console.log(
`[explore-artist] loaded: "${this.artistName}" (${summary})`,
);
} }
private async fetchArtist(mbid: string) { private async fetchArtist(mbid: string) {
@@ -412,7 +484,7 @@ export class ExploreArtistDetails extends LitElement {
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
this.errorArtist = msg; this.errorArtist = msg;
console.error(`[explore-artist] LookupArtist failed: ${msg}`); console.error(`[explore-artist] LookupArtist error: ${msg}`);
} finally { } finally {
this.loadingArtist = false; this.loadingArtist = false;
} }
@@ -426,7 +498,7 @@ export class ExploreArtistDetails extends LitElement {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
this.errorTracks = msg; this.errorTracks = msg;
console.error( console.error(
`[explore-artist] TopRecordingsForArtist failed: ${msg}`, `[explore-artist] TopRecordingsForArtist error: ${msg}`,
); );
} finally { } finally {
this.loadingTracks = false; this.loadingTracks = false;
@@ -441,13 +513,29 @@ export class ExploreArtistDetails extends LitElement {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg; this.errorReleases = msg;
console.error( console.error(
`[explore-artist] BrowseReleaseGroups failed: ${msg}`, `[explore-artist] BrowseReleaseGroups error: ${msg}`,
); );
} finally { } finally {
this.loadingReleases = false; this.loadingReleases = false;
} }
} }
private async fetchSimilarArtists(mbid: string) {
try {
const artists = await SimilarArtists(mbid);
this.similarArtists = artists ?? [];
} catch (err) {
// D024: graceful degradation — silently omit similar artists on failure.
const msg = err instanceof Error ? err.message : String(err);
console.error(
`[explore-artist] SimilarArtists error: ${msg}`,
);
this.similarArtists = [];
} finally {
this.loadingSimilar = false;
}
}
/* ── Navigation ── */ /* ── Navigation ── */
private navigateBack() { private navigateBack() {
@@ -474,6 +562,20 @@ export class ExploreArtistDetails extends LitElement {
); );
} }
private navigateToSimilarArtist(artist: LBSimilarArtist) {
this.dispatchEvent(
new CustomEvent('navigate', {
bubbles: true,
composed: true,
detail: {
view: 'explore-artist-details',
artistMBID: artist.artistMbid,
artistName: artist.name,
},
}),
);
}
/* ── Image Error Handling ── */ /* ── Image Error Handling ── */
private handleImageError(e: Event) { private handleImageError(e: Event) {
@@ -574,7 +676,7 @@ export class ExploreArtistDetails extends LitElement {
</div> </div>
<div class="content"> <div class="content">
${this.renderTopTracks()} ${this.renderDiscography()} ${this.renderTopTracks()} ${this.renderDiscography()}
<!-- similar artists section added in T02 --> ${this.renderSimilarArtists()}
</div> </div>
`; `;
} }
@@ -743,6 +845,56 @@ export class ExploreArtistDetails extends LitElement {
</div> </div>
`; `;
} }
/* ── Similar Artists Section ── */
private renderSimilarArtists() {
// D024: when loading or empty/null, simply omit the section.
if (this.loadingSimilar || this.similarArtists.length === 0) {
return nothing;
}
return html`
<section>
<h3 class="section-header">Similar Artists</h3>
<div class="horizontal-row">
${this.similarArtists.map((a) => {
const hue = nameToHue(a.name);
return html`
<div
class="similar-artist-card"
@click=${() => this.navigateToSimilarArtist(a)}
role="button"
tabindex="0"
@keydown=${(e: KeyboardEvent) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.preventDefault();
this.navigateToSimilarArtist(a);
}
}}
>
<div
class="similar-avatar"
style="background: hsl(${hue}, 45%, 35%)"
>
${a.name.charAt(0).toUpperCase()}
</div>
<div
class="similar-name"
title="${a.name}"
>
${a.name}
</div>
</div>
`;
})}
</div>
</section>
`;
}
} }
declare global { declare global {