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,
BrowseReleaseGroups,
TopRecordingsForArtist,
SimilarArtists,
} from '@go/explore/Service';
import type {
MBArtist,
MBReleaseGroup,
LBTopRecording,
LBSimilarArtist,
} from '@go/explore/Service';
import '@awesome.me/webawesome/dist/components/icon/icon.js';
@@ -67,6 +69,8 @@ export class ExploreArtistDetails extends LitElement {
@state() private errorArtist = '';
@state() private errorTracks = '';
@state() private errorReleases = '';
@state() private similarArtists: LBSimilarArtist[] = [];
@state() private loadingSimilar = true;
/* ── Styles ── */
@@ -370,6 +374,70 @@ export class ExploreArtistDetails extends LitElement {
color: var(--yj-text-tertiary, #888);
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() {
const mbid = this.artistMBID;
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.
const [artistResult, tracksResult, releasesResult] =
// Fire all four requests in parallel — each section is independent.
const [artistResult, tracksResult, releasesResult, similarResult] =
await Promise.allSettled([
this.fetchArtist(mbid),
this.fetchTopTracks(mbid),
this.fetchReleaseGroups(mbid),
this.fetchSimilarArtists(mbid),
]);
const summary = [
`artist=${artistResult.status}`,
`tracks=${tracksResult.status}`,
`releases=${releasesResult.status}`,
`similar=${similarResult.status}`,
].join(', ');
console.log(`[explore-artist] load complete: ${summary}`);
console.log(
`[explore-artist] loaded: "${this.artistName}" (${summary})`,
);
}
private async fetchArtist(mbid: string) {
@@ -412,7 +484,7 @@ export class ExploreArtistDetails extends LitElement {
} catch (err) {
const msg = err instanceof Error ? err.message : String(err);
this.errorArtist = msg;
console.error(`[explore-artist] LookupArtist failed: ${msg}`);
console.error(`[explore-artist] LookupArtist error: ${msg}`);
} finally {
this.loadingArtist = false;
}
@@ -426,7 +498,7 @@ export class ExploreArtistDetails extends LitElement {
const msg = err instanceof Error ? err.message : String(err);
this.errorTracks = msg;
console.error(
`[explore-artist] TopRecordingsForArtist failed: ${msg}`,
`[explore-artist] TopRecordingsForArtist error: ${msg}`,
);
} finally {
this.loadingTracks = false;
@@ -441,13 +513,29 @@ export class ExploreArtistDetails extends LitElement {
const msg = err instanceof Error ? err.message : String(err);
this.errorReleases = msg;
console.error(
`[explore-artist] BrowseReleaseGroups failed: ${msg}`,
`[explore-artist] BrowseReleaseGroups error: ${msg}`,
);
} finally {
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 ── */
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 ── */
private handleImageError(e: Event) {
@@ -574,7 +676,7 @@ export class ExploreArtistDetails extends LitElement {
</div>
<div class="content">
${this.renderTopTracks()} ${this.renderDiscography()}
<!-- similar artists section added in T02 -->
${this.renderSimilarArtists()}
</div>
`;
}
@@ -743,6 +845,56 @@ export class ExploreArtistDetails extends LitElement {
</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 {