From 6ee16c7a87324dcb1b00b0c90557ab17fd689dcf Mon Sep 17 00:00:00 2001 From: Caleb Allen Date: Mon, 23 Mar 2026 23:33:27 -0400 Subject: [PATCH] =?UTF-8?q?feat(S03/T01):=20Add=20explore-artist-details?= =?UTF-8?q?=20Lit=20component=20with=20artist=20hea=E2=80=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - frontend/src/components/explore-artist-details/explore-artist-details.ts - frontend/index.ts --- frontend/index.ts | 11 + .../explore-artist-details.ts | 752 ++++++++++++++++++ 2 files changed, 763 insertions(+) create mode 100644 frontend/src/components/explore-artist-details/explore-artist-details.ts diff --git a/frontend/index.ts b/frontend/index.ts index 3143e0d..42d452f 100644 --- a/frontend/index.ts +++ b/frontend/index.ts @@ -17,6 +17,7 @@ import '@components/search-bar/search-bar.ts'; import '@components/library-filter/library-filter.ts'; import '@components/track-details/track-details.ts'; import '@components/explore-view/explore-view.ts'; +import '@components/explore-artist-details/explore-artist-details.js'; import '@awesome.me/webawesome/dist/styles/themes/default.css'; import '@awesome.me/webawesome/dist/components/icon/icon.js'; import { setBasePath } from '@awesome.me/webawesome/dist/webawesome.js'; @@ -165,6 +166,16 @@ document.addEventListener('navigate', (e: Event) => { currentDetailEl = genreEl; break; } + case 'explore-artist-details': { + const { artistMBID, artistName } = detail; + const el = document.createElement('explore-artist-details'); + + el.setAttribute('artist-mbid', artistMBID); + el.setAttribute('artist-name', artistName); + mainContent.appendChild(el); + currentDetailEl = el; + break; + } default: { const fallback = document.createElement('div'); diff --git a/frontend/src/components/explore-artist-details/explore-artist-details.ts b/frontend/src/components/explore-artist-details/explore-artist-details.ts new file mode 100644 index 0000000..ce8dc04 --- /dev/null +++ b/frontend/src/components/explore-artist-details/explore-artist-details.ts @@ -0,0 +1,752 @@ +import { LitElement, html, css, nothing } from 'lit'; +import { customElement, property, state } from 'lit/decorators.js'; +import { designTokens } from '../../styles/tokens.css'; +import { + LookupArtist, + BrowseReleaseGroups, + TopRecordingsForArtist, +} from '@go/explore/Service'; +import type { + MBArtist, + MBReleaseGroup, + LBTopRecording, +} from '@go/explore/Service'; +import '@awesome.me/webawesome/dist/components/icon/icon.js'; + +/* ── Constants ── */ +const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group'; + +/** Desired section order for grouping release types. */ +const TYPE_ORDER = ['Album', 'EP', 'Single', 'Compilation']; + +/* ── Utility functions (duplicated from explore-view per design decision) ── */ + +function CoverArtGroupURL(releaseGroupMBID: string): string { + return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`; +} + +function nameToHue(name: string): number { + let hash = 0; + for (let i = 0; i < name.length; i++) { + hash = name.charCodeAt(i) + ((hash << 5) - hash); + } + return Math.abs(hash) % 360; +} + +function extractYear(dateStr: string): string { + if (!dateStr) return ''; + return dateStr.substring(0, 4); +} + +function formatListenCount(count: number): string { + if (count >= 1_000_000) return `${(count / 1_000_000).toFixed(1)}M`; + if (count >= 1_000) return `${(count / 1_000).toFixed(1)}K`; + return String(count); +} + +/* ── Component ── */ + +@customElement('explore-artist-details') +export class ExploreArtistDetails extends LitElement { + /* ── Public attributes ── */ + + @property({ type: String, attribute: 'artist-mbid' }) + artistMBID = ''; + + @property({ type: String, attribute: 'artist-name' }) + artistName = ''; + + /* ── Internal state ── */ + + @state() private artist: MBArtist | null = null; + @state() private topTracks: LBTopRecording[] = []; + @state() private releaseGroups: MBReleaseGroup[] = []; + @state() private loadingArtist = true; + @state() private loadingTracks = true; + @state() private loadingReleases = true; + @state() private errorArtist = ''; + @state() private errorTracks = ''; + @state() private errorReleases = ''; + + /* ── Styles ── */ + + static override styles = [ + designTokens, + css` + :host { + display: flex; + flex-direction: column; + overflow: hidden; + height: 100%; + } + + /* ── Header ── */ + .artist-header { + display: flex; + align-items: center; + gap: 20px; + padding: 16px 20px; + flex-shrink: 0; + border-bottom: 1px solid + var(--yj-border-subtle, rgba(255, 255, 255, 0.06)); + } + + .back-button { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border: none; + border-radius: 50%; + background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06)); + color: var(--yj-text-primary, #fff); + cursor: pointer; + flex-shrink: 0; + transition: background-color 0.15s ease; + } + + .back-button:hover { + background: var(--yj-bg-hover, rgba(255, 255, 255, 0.12)); + } + + .back-button wa-icon { + font-size: 16px; + } + + .artist-avatar { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-weight: 600; + font-size: 32px; + text-transform: uppercase; + user-select: none; + flex-shrink: 0; + line-height: 1; + } + + .artist-info { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + } + + .artist-title { + font-size: 24px; + font-weight: 700; + color: var(--yj-text-primary, #fff); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin: 0; + line-height: 1.2; + } + + .artist-meta { + font-size: var(--yj-text-md); + color: var(--yj-text-secondary, #b3b3b3); + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + } + + .meta-separator { + opacity: 0.4; + } + + /* ── Scrollable content ── */ + .content { + flex: 1; + overflow-y: auto; + padding: 20px 24px 32px; + display: flex; + flex-direction: column; + gap: 32px; + } + + /* ── Section headers ── */ + .section-header { + font-size: 11px; + font-weight: 600; + color: var(--yj-text-secondary, #b3b3b3); + text-transform: uppercase; + letter-spacing: 0.05em; + margin: 0 0 12px; + } + + /* ── Loading / error states ── */ + .section-loading { + color: var(--yj-text-secondary, #b3b3b3); + font-size: var(--yj-text-md); + animation: pulse 1.5s ease-in-out infinite; + } + + @keyframes pulse { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } + } + + .section-error { + display: flex; + align-items: center; + gap: 6px; + color: var(--yj-text-secondary, #b3b3b3); + font-size: var(--yj-text-sm); + } + + .section-error wa-icon { + color: #e5534b; + font-size: var(--yj-icon-sm); + flex-shrink: 0; + } + + /* ── Top tracks ── */ + .track-list { + display: flex; + flex-direction: column; + } + + .track-item { + display: flex; + align-items: center; + gap: 12px; + padding: 8px 12px; + border-radius: 6px; + cursor: default; + transition: background 0.1s ease; + } + + .track-item:hover { + background: var( + --yj-bg-overlay, + rgba(255, 255, 255, 0.04) + ); + } + + .track-rank { + width: 24px; + text-align: right; + color: var(--yj-text-tertiary, #888); + font-size: var(--yj-text-md); + font-variant-numeric: tabular-nums; + flex-shrink: 0; + } + + .track-info { + flex: 1; + min-width: 0; + } + + .track-title { + font-weight: 500; + color: var(--yj-text-primary, #fff); + font-size: var(--yj-text-md); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .track-artist { + color: var(--yj-text-tertiary, #888); + font-size: var(--yj-text-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .track-listens { + color: var(--yj-text-tertiary, #888); + font-size: var(--yj-text-sm); + flex-shrink: 0; + font-variant-numeric: tabular-nums; + white-space: nowrap; + } + + /* ── Discography grid ── */ + .disco-group { + display: flex; + flex-direction: column; + gap: 12px; + } + + .disco-type-header { + font-size: var(--yj-text-md); + font-weight: 600; + color: var(--yj-text-primary, #fff); + margin: 0; + } + + .album-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); + gap: 16px; + } + + .album-card { + display: flex; + flex-direction: column; + gap: 6px; + padding: 8px; + border-radius: 8px; + cursor: pointer; + transition: background 0.15s ease; + } + + .album-card:hover { + background: var( + --yj-bg-overlay, + rgba(255, 255, 255, 0.06) + ); + } + + .album-card:active { + transform: scale(0.97); + } + + .album-art-container { + width: 100%; + aspect-ratio: 1; + border-radius: 4px; + overflow: hidden; + background: linear-gradient( + 135deg, + var(--yj-bg-overlay, #404040) 0%, + var(--yj-bg-surface, #282828) 100% + ); + display: flex; + align-items: center; + justify-content: center; + position: relative; + } + + .album-art-container img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; + } + + .album-art-fallback { + display: flex; + align-items: center; + justify-content: center; + width: 100%; + height: 100%; + position: absolute; + inset: 0; + } + + .album-art-fallback wa-icon { + color: var(--yj-text-tertiary, #888); + font-size: 24px; + opacity: 0.5; + } + + .album-title { + font-weight: 500; + color: var(--yj-text-primary, #fff); + font-size: var(--yj-text-sm); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .album-meta { + display: flex; + align-items: center; + gap: 6px; + color: var(--yj-text-tertiary, #888); + font-size: var(--yj-text-xs); + } + `, + ]; + + /* ── Lifecycle ── */ + + override connectedCallback() { + super.connectedCallback(); + if (this.artistMBID) { + void this.loadAllData(); + } + } + + /* ── Data Loading ── */ + + private async loadAllData() { + const mbid = this.artistMBID; + console.log( + `[explore-artist] loading artist page: "${this.artistName}" (${mbid})`, + ); + + // Fire all three requests in parallel — each section is independent. + const [artistResult, tracksResult, releasesResult] = + await Promise.allSettled([ + this.fetchArtist(mbid), + this.fetchTopTracks(mbid), + this.fetchReleaseGroups(mbid), + ]); + + const summary = [ + `artist=${artistResult.status}`, + `tracks=${tracksResult.status}`, + `releases=${releasesResult.status}`, + ].join(', '); + console.log(`[explore-artist] load complete: ${summary}`); + } + + private async fetchArtist(mbid: string) { + try { + this.artist = await LookupArtist(mbid); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.errorArtist = msg; + console.error(`[explore-artist] LookupArtist failed: ${msg}`); + } finally { + this.loadingArtist = false; + } + } + + private async fetchTopTracks(mbid: string) { + try { + const tracks = await TopRecordingsForArtist(mbid); + this.topTracks = tracks ?? []; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.errorTracks = msg; + console.error( + `[explore-artist] TopRecordingsForArtist failed: ${msg}`, + ); + } finally { + this.loadingTracks = false; + } + } + + private async fetchReleaseGroups(mbid: string) { + try { + const rgs = await BrowseReleaseGroups(mbid); + this.releaseGroups = rgs ?? []; + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + this.errorReleases = msg; + console.error( + `[explore-artist] BrowseReleaseGroups failed: ${msg}`, + ); + } finally { + this.loadingReleases = false; + } + } + + /* ── Navigation ── */ + + private navigateBack() { + this.dispatchEvent( + new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { view: 'explore' }, + }), + ); + } + + private navigateToAlbum(rg: MBReleaseGroup) { + this.dispatchEvent( + new CustomEvent('navigate', { + bubbles: true, + composed: true, + detail: { + view: 'explore-album-details', + releaseGroupMBID: rg.mbid, + albumName: rg.title, + }, + }), + ); + } + + /* ── Image Error Handling ── */ + + private handleImageError(e: Event) { + const img = e.target as HTMLImageElement; + img.style.display = 'none'; + const fallback = img.nextElementSibling as HTMLElement | null; + if (fallback) { + fallback.style.display = 'flex'; + } + } + + /* ── Helpers ── */ + + private getInitial(name: string): string { + if (!name) return '?'; + return name.charAt(0).toUpperCase(); + } + + /** + * Group release groups by primaryType, returning entries in the + * canonical order: Album → EP → Single → Compilation → Other. + */ + private groupByType(): Array<{ type: string; items: MBReleaseGroup[] }> { + const map = new Map(); + + for (const rg of this.releaseGroups) { + const key = rg.primaryType || 'Other'; + let bucket = map.get(key); + if (!bucket) { + bucket = []; + map.set(key, bucket); + } + bucket.push(rg); + } + + // Sort each bucket by firstReleaseDate descending (newest first). + for (const bucket of map.values()) { + bucket.sort((a, b) => { + const da = a.firstReleaseDate || ''; + const db = b.firstReleaseDate || ''; + return db.localeCompare(da); + }); + } + + // Build ordered result following TYPE_ORDER, then any remaining types. + const result: Array<{ type: string; items: MBReleaseGroup[] }> = []; + const seen = new Set(); + + for (const type of TYPE_ORDER) { + const items = map.get(type); + if (items && items.length > 0) { + result.push({ type, items }); + seen.add(type); + } + } + + // Remaining types not in TYPE_ORDER (alphabetical). + const remaining = [...map.keys()] + .filter((k) => !seen.has(k)) + .sort(); + for (const type of remaining) { + const items = map.get(type); + if (items && items.length > 0) { + result.push({ type, items }); + } + } + + return result; + } + + /* ── Render ── */ + + override render() { + const hue = nameToHue(this.artistName); + + return html` +
+ +
+ ${this.getInitial(this.artistName)} +
+
+

+ ${this.artistName} +

+ ${this.renderArtistMeta()} +
+
+
+ ${this.renderTopTracks()} ${this.renderDiscography()} + +
+ `; + } + + private renderArtistMeta() { + if (this.loadingArtist) { + return html`Loading\u2026`; + } + if (this.errorArtist) { + return html`${this.errorArtist}`; + } + if (!this.artist) return nothing; + + const parts: string[] = []; + if (this.artist.type) parts.push(this.artist.type); + if (this.artist.country) parts.push(this.artist.country); + if (this.artist.disambiguation) parts.push(this.artist.disambiguation); + + if (parts.length === 0) return nothing; + + return html` + + ${parts.map( + (p, i) => + html`${i > 0 + ? html`\u00B7` + : nothing}${p}`, + )} + + `; + } + + /* ── Top Tracks Section ── */ + + private renderTopTracks() { + if (this.loadingTracks) { + return html` +
+

Top Tracks

+
Loading\u2026
+
+ `; + } + if (this.errorTracks) { + return html` +
+

Top Tracks

+
+ + ${this.errorTracks} +
+
+ `; + } + if (this.topTracks.length === 0) return nothing; + + return html` +
+

Top Tracks

+
+ ${this.topTracks.map( + (t, i) => html` +
+ ${i + 1} +
+
+ ${t.trackName} +
+
+ ${t.artistName} +
+
+ + ${formatListenCount(t.totalListenCount)} + plays + +
+ `, + )} +
+
+ `; + } + + /* ── Discography Section ── */ + + private renderDiscography() { + if (this.loadingReleases) { + return html` +
+

Discography

+
Loading\u2026
+
+ `; + } + if (this.errorReleases) { + return html` +
+

Discography

+
+ + ${this.errorReleases} +
+
+ `; + } + if (this.releaseGroups.length === 0) return nothing; + + const groups = this.groupByType(); + + return html` +
+

Discography

+ ${groups.map( + (g) => html` +
+

+ ${g.type === 'Other' ? 'Other Releases' : `${g.type}s`} +

+
+ ${g.items.map((rg) => this.renderAlbumCard(rg))} +
+
+ `, + )} +
+ `; + } + + private renderAlbumCard(rg: MBReleaseGroup) { + const artURL = CoverArtGroupURL(rg.mbid); + const year = extractYear(rg.firstReleaseDate); + + return html` +
this.navigateToAlbum(rg)} + role="button" + tabindex="0" + @keydown=${(e: KeyboardEvent) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + this.navigateToAlbum(rg); + } + }} + > +
+ ${rg.title} + +
+
${rg.title}
+
+ ${year ? html`${year}` : nothing} +
+
+ `; + } +} + +declare global { + interface HTMLElementTagNameMap { + 'explore-artist-details': ExploreArtistDetails; + } +}