chore(M004/S02): auto-commit after complete-slice
This commit is contained in:
@@ -1,36 +1,907 @@
|
||||
import { LitElement, html, css } from 'lit';
|
||||
import { customElement } from 'lit/decorators.js';
|
||||
import { LitElement, html, css, nothing } from 'lit';
|
||||
import { customElement, state, query as litQuery } from 'lit/decorators.js';
|
||||
import { designTokens } from '../../styles/tokens.css';
|
||||
import { Search } from '@go/explore/Service';
|
||||
import type {
|
||||
MBSearchResult,
|
||||
MBArtist,
|
||||
MBReleaseGroup,
|
||||
MBRecording,
|
||||
} from '@go/explore/Service';
|
||||
import '@awesome.me/webawesome/dist/components/icon/icon.js';
|
||||
|
||||
/* ── Constants ── */
|
||||
const DEBOUNCE_MS = 300;
|
||||
const CAA_GROUP_BASE = 'https://coverartarchive.org/release-group';
|
||||
const TOP_RESULTS_COUNT = 3;
|
||||
|
||||
/**
|
||||
* Build a Cover Art Archive URL for a release-group front cover.
|
||||
* Mirrors the Wails CoverArtGroupURL binding but runs synchronously
|
||||
* on the frontend — avoids N async round-trips per render cycle.
|
||||
*/
|
||||
function CoverArtGroupURL(releaseGroupMBID: string): string {
|
||||
return `${CAA_GROUP_BASE}/${releaseGroupMBID}/front-250`;
|
||||
}
|
||||
|
||||
/** Hash a string to a hue value 0–360 for avatar coloring. */
|
||||
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;
|
||||
}
|
||||
|
||||
/** Format milliseconds as mm:ss. */
|
||||
function formatDuration(ms: number): string {
|
||||
if (!ms || ms <= 0) return '0:00';
|
||||
const totalSeconds = Math.floor(ms / 1000);
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return `${minutes}:${seconds.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
/** Extract the year from a date string like "2005-03-29" or "2005". */
|
||||
function extractYear(dateStr: string): string {
|
||||
if (!dateStr) return '';
|
||||
return dateStr.substring(0, 4);
|
||||
}
|
||||
|
||||
/**
|
||||
* Union type for top-results ranking. We can only rank items with
|
||||
* a score field — MBReleaseGroup lacks one in the Go struct.
|
||||
*/
|
||||
interface ScoredItem {
|
||||
type: 'artist' | 'recording';
|
||||
score: number;
|
||||
artist?: MBArtist;
|
||||
recording?: MBRecording;
|
||||
}
|
||||
|
||||
@customElement('explore-view')
|
||||
export class ExploreView extends LitElement {
|
||||
/* ── State ── */
|
||||
|
||||
@state() private searchQuery = '';
|
||||
@state() private results: MBSearchResult | null = null;
|
||||
@state() private loading = false;
|
||||
@state() private error = '';
|
||||
|
||||
/** Monotonic counter to discard stale responses. */
|
||||
private searchVersion = 0;
|
||||
private debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
@litQuery('input') private inputEl!: HTMLInputElement;
|
||||
|
||||
/* ── Styles ── */
|
||||
|
||||
static override styles = [
|
||||
designTokens,
|
||||
css`
|
||||
:host {
|
||||
display: block;
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
height: 100%;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 0 0 16px;
|
||||
font-size: 1.5rem;
|
||||
color: var(--yj-text-primary);
|
||||
/* ── Search input ── */
|
||||
.search-container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
border: 1px solid var(--yj-border-subtle, #555);
|
||||
border-radius: 6px;
|
||||
padding: 0 12px;
|
||||
gap: 8px;
|
||||
height: 36px;
|
||||
max-width: 520px;
|
||||
transition: border-color 0.15s ease;
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
color: var(--yj-text-secondary);
|
||||
.search-container:focus-within {
|
||||
border-color: var(--yj-accent, #ffd43b);
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-icon-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
font-size: var(--yj-text-md);
|
||||
font-family: inherit;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
input::placeholder {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
}
|
||||
|
||||
.clear-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
font-size: var(--yj-text-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.clear-button:hover {
|
||||
color: var(--yj-text-primary, #fff);
|
||||
}
|
||||
|
||||
/* ── Status messages ── */
|
||||
.status-message {
|
||||
margin-top: 32px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-md);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
margin-top: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
font-size: var(--yj-text-sm);
|
||||
}
|
||||
|
||||
.error-message wa-icon {
|
||||
color: #e5534b;
|
||||
font-size: var(--yj-icon-sm);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
margin-top: 24px;
|
||||
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; }
|
||||
}
|
||||
|
||||
/* ── Sections ── */
|
||||
.results-container {
|
||||
margin-top: 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 28px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
font-size: var(--yj-text-lg);
|
||||
font-weight: 600;
|
||||
color: var(--yj-text-secondary, #b3b3b3);
|
||||
margin: 0 0 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
/* ── Horizontal scroll rows ── */
|
||||
.horizontal-row {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
overflow-x: auto;
|
||||
padding-bottom: 4px;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.horizontal-row::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
/* ── Top result cards ── */
|
||||
.top-card {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 10px 14px;
|
||||
background: var(--yj-bg-surface, #212529);
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
min-width: 200px;
|
||||
max-width: 280px;
|
||||
flex-shrink: 0;
|
||||
transition: background 0.15s ease;
|
||||
}
|
||||
|
||||
.top-card:hover {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.top-card:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
|
||||
.top-card-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.top-card-name {
|
||||
font-weight: 500;
|
||||
color: var(--yj-text-primary, #fff);
|
||||
font-size: var(--yj-text-md);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.top-card-meta {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-sm);
|
||||
margin-top: 2px;
|
||||
}
|
||||
|
||||
/* ── Artist cards ── */
|
||||
.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;
|
||||
}
|
||||
|
||||
.artist-card:hover {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.06));
|
||||
}
|
||||
|
||||
.artist-card:active {
|
||||
transform: scale(0.97);
|
||||
}
|
||||
|
||||
.artist-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;
|
||||
}
|
||||
|
||||
.artist-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%;
|
||||
}
|
||||
|
||||
.artist-disambiguation {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
margin-top: -4px;
|
||||
}
|
||||
|
||||
.artist-country {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
}
|
||||
|
||||
/* ── Album cards ── */
|
||||
.album-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 8px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
min-width: 130px;
|
||||
max-width: 150px;
|
||||
flex-shrink: 0;
|
||||
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-artist {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-xs);
|
||||
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);
|
||||
}
|
||||
|
||||
.type-badge {
|
||||
background: var(--yj-bg-overlay, rgba(255, 255, 255, 0.08));
|
||||
padding: 1px 6px;
|
||||
border-radius: 3px;
|
||||
font-size: 10px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* ── Track list ── */
|
||||
.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-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-duration {
|
||||
color: var(--yj-text-tertiary, #888);
|
||||
font-size: var(--yj-text-sm);
|
||||
flex-shrink: 0;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
`,
|
||||
];
|
||||
|
||||
/* ── Lifecycle ── */
|
||||
|
||||
override disconnectedCallback() {
|
||||
super.disconnectedCallback();
|
||||
if (this.debounceTimer !== null) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Search Logic ── */
|
||||
|
||||
private handleInput(e: Event) {
|
||||
const input = e.target as HTMLInputElement;
|
||||
this.searchQuery = input.value;
|
||||
|
||||
if (this.debounceTimer !== null) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
|
||||
if (!this.searchQuery.trim()) {
|
||||
this.results = null;
|
||||
this.error = '';
|
||||
this.loading = false;
|
||||
return;
|
||||
}
|
||||
|
||||
this.debounceTimer = setTimeout(() => {
|
||||
this.debounceTimer = null;
|
||||
void this.executeSearch();
|
||||
}, DEBOUNCE_MS);
|
||||
}
|
||||
|
||||
private handleClear() {
|
||||
this.searchQuery = '';
|
||||
this.results = null;
|
||||
this.error = '';
|
||||
this.loading = false;
|
||||
if (this.debounceTimer !== null) {
|
||||
clearTimeout(this.debounceTimer);
|
||||
this.debounceTimer = null;
|
||||
}
|
||||
if (this.inputEl) {
|
||||
this.inputEl.value = '';
|
||||
this.inputEl.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private handleKeydown(e: KeyboardEvent) {
|
||||
if (e.key === 'Escape') {
|
||||
this.handleClear();
|
||||
}
|
||||
}
|
||||
|
||||
private async executeSearch() {
|
||||
const version = ++this.searchVersion;
|
||||
const query = this.searchQuery.trim();
|
||||
if (!query) return;
|
||||
|
||||
this.loading = true;
|
||||
this.error = '';
|
||||
|
||||
const startTime = performance.now();
|
||||
console.log(`[explore] search started: "${query}"`);
|
||||
|
||||
try {
|
||||
const result = await Search(query);
|
||||
|
||||
// Discard stale response
|
||||
if (version !== this.searchVersion) {
|
||||
console.debug(
|
||||
`[explore] discarded stale response for "${query}" (v${version} != v${this.searchVersion})`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.results = result;
|
||||
const elapsed = (performance.now() - startTime).toFixed(0);
|
||||
console.log(
|
||||
`[explore] search completed: "${query}" in ${elapsed}ms — ` +
|
||||
`artists=${result.artists?.length ?? 0}, ` +
|
||||
`albums=${result.releaseGroups?.length ?? 0}, ` +
|
||||
`tracks=${result.recordings?.length ?? 0}`,
|
||||
);
|
||||
} catch (err) {
|
||||
if (version !== this.searchVersion) return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
this.error = message;
|
||||
console.error(`[explore] search error: "${query}" — ${message}`);
|
||||
} finally {
|
||||
if (version === this.searchVersion) {
|
||||
this.loading = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Top Results ── */
|
||||
|
||||
private getTopResults(): ScoredItem[] {
|
||||
if (!this.results) return [];
|
||||
|
||||
const items: ScoredItem[] = [];
|
||||
|
||||
if (this.results.artists) {
|
||||
for (const a of this.results.artists) {
|
||||
items.push({ type: 'artist', score: a.score, artist: a });
|
||||
}
|
||||
}
|
||||
|
||||
if (this.results.recordings) {
|
||||
for (const r of this.results.recordings) {
|
||||
items.push({ type: 'recording', score: r.score, recording: r });
|
||||
}
|
||||
}
|
||||
|
||||
items.sort((a, b) => b.score - a.score);
|
||||
return items.slice(0, TOP_RESULTS_COUNT);
|
||||
}
|
||||
|
||||
/* ── Navigation ── */
|
||||
|
||||
private navigateToArtist(artist: MBArtist) {
|
||||
this.dispatchEvent(
|
||||
new CustomEvent('navigate', {
|
||||
bubbles: true,
|
||||
composed: true,
|
||||
detail: {
|
||||
view: 'explore-artist-details',
|
||||
artistMBID: artist.mbid,
|
||||
artistName: artist.name,
|
||||
},
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
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';
|
||||
// Reveal the fallback placeholder behind the image
|
||||
const fallback = img.nextElementSibling as HTMLElement | null;
|
||||
if (fallback) {
|
||||
fallback.style.display = 'flex';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Render ── */
|
||||
|
||||
override render() {
|
||||
return html`
|
||||
<h1>Explore</h1>
|
||||
<p class="placeholder">
|
||||
Search MusicBrainz to discover artists, albums, and
|
||||
tracks.
|
||||
</p>
|
||||
${this.renderSearchInput()}
|
||||
${this.loading
|
||||
? html`<div class="loading-indicator">Searching\u2026</div>`
|
||||
: nothing}
|
||||
${this.error
|
||||
? html`<div class="error-message">
|
||||
<wa-icon name="triangle-exclamation"></wa-icon>
|
||||
${this.error}
|
||||
</div>`
|
||||
: nothing}
|
||||
${this.renderBody()}
|
||||
`;
|
||||
}
|
||||
|
||||
private renderSearchInput() {
|
||||
return html`
|
||||
<div class="search-container">
|
||||
<wa-icon class="search-icon" name="magnifying-glass"></wa-icon>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search MusicBrainz\u2026"
|
||||
.value=${this.searchQuery}
|
||||
@input=${this.handleInput}
|
||||
@keydown=${this.handleKeydown}
|
||||
/>
|
||||
${this.searchQuery
|
||||
? html`
|
||||
<button
|
||||
class="clear-button"
|
||||
@click=${this.handleClear}
|
||||
>
|
||||
<wa-icon name="xmark"></wa-icon>
|
||||
</button>
|
||||
`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderBody() {
|
||||
// No query entered yet
|
||||
if (!this.searchQuery.trim() && !this.results) {
|
||||
return html`<div class="status-message">
|
||||
Search MusicBrainz to discover artists, albums, and tracks.
|
||||
</div>`;
|
||||
}
|
||||
|
||||
// Loading state already shown above
|
||||
if (this.loading && !this.results) return nothing;
|
||||
|
||||
// Results exist
|
||||
if (this.results) {
|
||||
const hasArtists = (this.results.artists?.length ?? 0) > 0;
|
||||
const hasAlbums = (this.results.releaseGroups?.length ?? 0) > 0;
|
||||
const hasTracks = (this.results.recordings?.length ?? 0) > 0;
|
||||
|
||||
if (!hasArtists && !hasAlbums && !hasTracks) {
|
||||
return html`<div class="status-message">
|
||||
No results found for \u201c${this.searchQuery}\u201d
|
||||
</div>`;
|
||||
}
|
||||
|
||||
const topResults = this.getTopResults();
|
||||
|
||||
return html`
|
||||
<div class="results-container">
|
||||
${topResults.length > 0
|
||||
? this.renderTopResults(topResults)
|
||||
: nothing}
|
||||
${hasArtists
|
||||
? this.renderArtistsSection(this.results.artists!)
|
||||
: nothing}
|
||||
${hasAlbums
|
||||
? this.renderAlbumsSection(this.results.releaseGroups!)
|
||||
: nothing}
|
||||
${hasTracks
|
||||
? this.renderTracksSection(this.results.recordings!)
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
/* ── Section Renderers ── */
|
||||
|
||||
private renderTopResults(items: ScoredItem[]) {
|
||||
return html`
|
||||
<section>
|
||||
<h3 class="section-header">Top Results</h3>
|
||||
<div class="horizontal-row">
|
||||
${items.map((item) => this.renderTopCard(item))}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTopCard(item: ScoredItem) {
|
||||
if (item.type === 'artist' && item.artist) {
|
||||
const a = item.artist;
|
||||
const hue = nameToHue(a.name);
|
||||
return html`
|
||||
<div
|
||||
class="top-card"
|
||||
@click=${() => this.navigateToArtist(a)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.navigateToArtist(a);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="artist-avatar"
|
||||
style="background: hsl(${hue}, 45%, 35%)"
|
||||
>
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="top-card-info">
|
||||
<div class="top-card-name">${a.name}</div>
|
||||
<div class="top-card-meta">Artist${a.country ? ` · ${a.country}` : ''}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
if (item.type === 'recording' && item.recording) {
|
||||
const r = item.recording;
|
||||
return html`
|
||||
<div class="top-card" role="listitem">
|
||||
<wa-icon
|
||||
style="color: var(--yj-text-tertiary, #888); font-size: 18px; flex-shrink: 0;"
|
||||
name="music"
|
||||
></wa-icon>
|
||||
<div class="top-card-info">
|
||||
<div class="top-card-name">${r.title}</div>
|
||||
<div class="top-card-meta">
|
||||
${r.artistCredit}${r.length
|
||||
? ` · ${formatDuration(r.length)}`
|
||||
: ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
return nothing;
|
||||
}
|
||||
|
||||
private renderArtistsSection(artists: MBArtist[]) {
|
||||
return html`
|
||||
<section>
|
||||
<h3 class="section-header">Artists</h3>
|
||||
<div class="horizontal-row">
|
||||
${artists.map((a) => {
|
||||
const hue = nameToHue(a.name);
|
||||
return html`
|
||||
<div
|
||||
class="artist-card"
|
||||
@click=${() => this.navigateToArtist(a)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.navigateToArtist(a);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div
|
||||
class="artist-avatar"
|
||||
style="background: hsl(${hue}, 45%, 35%)"
|
||||
>
|
||||
${a.name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
<div class="artist-name" title="${a.name}">
|
||||
${a.name}
|
||||
</div>
|
||||
${a.disambiguation
|
||||
? html`<div class="artist-disambiguation">
|
||||
${a.disambiguation}
|
||||
</div>`
|
||||
: nothing}
|
||||
${a.country
|
||||
? html`<div class="artist-country">
|
||||
${a.country}
|
||||
</div>`
|
||||
: nothing}
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderAlbumsSection(releaseGroups: MBReleaseGroup[]) {
|
||||
return html`
|
||||
<section>
|
||||
<h3 class="section-header">Albums</h3>
|
||||
<div class="horizontal-row">
|
||||
${releaseGroups.map((rg) => {
|
||||
const artURL = CoverArtGroupURL(rg.mbid);
|
||||
const year = extractYear(rg.firstReleaseDate);
|
||||
return html`
|
||||
<div
|
||||
class="album-card"
|
||||
@click=${() => this.navigateToAlbum(rg)}
|
||||
role="button"
|
||||
tabindex="0"
|
||||
@keydown=${(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
this.navigateToAlbum(rg);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div class="album-art-container">
|
||||
<img
|
||||
src="${artURL}"
|
||||
alt="${rg.title}"
|
||||
loading="lazy"
|
||||
@error=${this.handleImageError}
|
||||
/>
|
||||
<div
|
||||
class="album-art-fallback"
|
||||
style="display: none"
|
||||
>
|
||||
<wa-icon name="compact-disc"></wa-icon>
|
||||
</div>
|
||||
</div>
|
||||
<div class="album-title" title="${rg.title}">
|
||||
${rg.title}
|
||||
</div>
|
||||
<div class="album-artist">${rg.artistCredit}</div>
|
||||
<div class="album-meta">
|
||||
${rg.primaryType
|
||||
? html`<span class="type-badge"
|
||||
>${rg.primaryType}</span
|
||||
>`
|
||||
: nothing}
|
||||
${year ? html`<span>${year}</span>` : nothing}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
|
||||
private renderTracksSection(recordings: MBRecording[]) {
|
||||
return html`
|
||||
<section>
|
||||
<h3 class="section-header">Tracks</h3>
|
||||
<div class="track-list">
|
||||
${recordings.map(
|
||||
(r) => html`
|
||||
<div class="track-item">
|
||||
<div class="track-info">
|
||||
<div class="track-title">${r.title}</div>
|
||||
<div class="track-artist">
|
||||
${r.artistCredit}
|
||||
</div>
|
||||
</div>
|
||||
<div class="track-duration">
|
||||
${formatDuration(r.length)}
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user