From acc9d8b5cde262a347d3a082f5c8b22d8b9d761d Mon Sep 17 00:00:00 2001 From: BlackSigkill Date: Thu, 19 Feb 2026 23:37:18 +0100 Subject: [PATCH 1/3] add tidal biography to artists pages --- index.html | 1 + js/api.js | 31 ++++++++++ js/music-api.js | 10 ++++ js/qobuz-api.js | 18 +++++- js/ui.js | 148 ++++++++++++++++++++++++++++++++++++++++++++++++ styles.css | 57 +++++++++++++++++++ 6 files changed, 264 insertions(+), 1 deletion(-) diff --git a/index.html b/index.html index 0720add..4a8edb2 100644 --- a/index.html +++ b/index.html @@ -2605,6 +2605,7 @@

+
+
+ +
+ `; + + document.body.appendChild(modal); + + const close = (e) => { + if (e) { + e.preventDefault(); + e.stopPropagation(); + } + modal.remove(); + }; + + modal.querySelector('.modal-overlay').onclick = close; + modal.querySelector('.btn-close').onclick = close; + + // Ensure links are clickable by attaching the listener to the modal body + const modalBody = modal.querySelector('.modal-body'); + modalBody.addEventListener('click', (e) => { + const link = e.target.closest('.bio-link'); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const { type, id } = link.dataset; + if (type && id) { + modal.remove(); + navigate(`/${type}/t/${id}`); + } + } + }, true); // Use capture phase to ensure it's hit + }; + + const renderBioPreview = (bio) => { + const text = typeof bio === 'string' ? bio : bio.text; + if (text) { + // Use stripped text for preview to avoid broken tags/links + const cleanText = stripBioTags(text); + const isLong = cleanText.length > 200; + const previewText = isLong ? cleanText.substring(0, 200).trim() + '...' : cleanText; + + bioEl.innerHTML = previewText.replace(/\n/g, '
'); + bioEl.style.display = 'block'; + bioEl.style.webkitLineClamp = 'unset'; + bioEl.style.cursor = 'default'; + bioEl.onclick = null; + + if (isLong) { + bioEl.appendChild(document.createElement('br')); + const readMore = document.createElement('span'); + readMore.className = 'bio-read-more'; + readMore.textContent = 'Read More'; + readMore.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + showBioModal(bio); + }; + bioEl.appendChild(readMore); + } + } else { + bioEl.style.display = 'none'; + } + }; + + if (artist.biography) { + renderBioPreview(artist.biography); + } else { + // Try to fetch biography asynchronously + this.api + .getArtistBiography(artistId, provider) + .then((bio) => { + if (bio) renderBioPreview(bio); + }) + .catch(() => { + /* ignore */ + }); + } + } + // Handle Artist Mix Button const mixBtn = document.getElementById('artist-mix-btn'); if (mixBtn) { diff --git a/styles.css b/styles.css index 5ea8c3a..0bbd4ff 100644 --- a/styles.css +++ b/styles.css @@ -2360,6 +2360,59 @@ input[type='search']::-webkit-search-cancel-button { color: var(--highlight); } +/* Artist Biography Styles */ +.artist-bio { + color: var(--muted-foreground); + margin-top: 1rem; + font-size: 0.95rem; + line-height: 1.5; + max-width: 600px; + display: block; + overflow: hidden; + transition: color var(--transition); +} + +.artist-bio:hover { + color: var(--foreground); +} + +.bio-link { + color: var(--highlight) !important; + text-decoration: underline !important; + cursor: pointer !important; + font-weight: 500 !important; + pointer-events: auto !important; +} + +.bio-link:hover { + color: var(--primary) !important; + text-decoration: none !important; +} + +.bio-read-more { + display: block; + color: var(--highlight); + text-decoration: underline; + cursor: pointer; + font-weight: bold; + font-size: 0.95rem; + margin-top: 0.5rem; +} + +.bio-read-more:hover { + color: var(--primary); +} + +.bio-source { + display: block; + margin-top: 1.5rem; + font-size: 0.9rem; + opacity: 0.6; + font-style: italic; + border-top: 1px solid var(--border); + padding-top: 1rem; +} + .detail-header-actions { display: flex; gap: 0.5rem; @@ -4999,6 +5052,10 @@ img[src=''] { max-width: 600px; } +.modal-content.extra-wide { + max-width: 1000px; +} + .modal-content.medium { max-width: 500px; } From 242541a7bb8af859f45c258bded3beab7e13e6a8 Mon Sep 17 00:00:00 2001 From: BlackSigkill Date: Thu, 19 Feb 2026 23:45:43 +0100 Subject: [PATCH 2/3] remove qobuz bio as i can't test it --- js/music-api.js | 2 ++ js/qobuz-api.js | 18 +----------------- 2 files changed, 3 insertions(+), 17 deletions(-) diff --git a/js/music-api.js b/js/music-api.js index c81a001..51ac906 100644 --- a/js/music-api.js +++ b/js/music-api.js @@ -78,6 +78,8 @@ export class MusicAPI { async getArtistBiography(id, provider = null) { const p = provider || this.getProviderFromId(id) || this.getCurrentProvider(); + if (p !== 'tidal') return null; // Biography only supported for Tidal + const api = this.getAPI(p); const cleanId = this.stripProviderPrefix(id); if (typeof api.getArtistBiography === 'function') { diff --git a/js/qobuz-api.js b/js/qobuz-api.js index 9ffaa39..6d03049 100644 --- a/js/qobuz-api.js +++ b/js/qobuz-api.js @@ -179,29 +179,13 @@ export class QobuzAPI { tracks = artistData.data.top_tracks.map((track) => this.transformTrack(track)); } - // Get biography - const biography = artistData.data.biography || artistInfo.biography || null; - - return { ...artist, albums, eps, tracks, biography }; + return { ...artist, albums, eps, tracks }; } catch (error) { console.error('Qobuz getArtist failed:', error); throw error; } } - // Qobuz biography - usually part of getArtist, but adding for API consistency - async getArtistBiography(id) { - try { - const data = await this.getArtist(id); - return { - text: data.biography, - source: 'Qobuz', - }; - } catch (e) { - return null; - } - } - // Transform Qobuz track to Tidal-like format transformTrack(track, albumData = null) { // Qobuz uses 'performer' for the main artist, not 'artist' From cefc49777ce6eea299b33567a9b078dd5ed43c2b Mon Sep 17 00:00:00 2001 From: BlackSigkill Date: Thu, 19 Feb 2026 23:48:44 +0100 Subject: [PATCH 3/3] lint & prettier --- js/ui.js | 57 +++++++++++++++++++++++++++++++++++--------------------- 1 file changed, 36 insertions(+), 21 deletions(-) diff --git a/js/ui.js b/js/ui.js index ece85da..b2dac27 100644 --- a/js/ui.js +++ b/js/ui.js @@ -2915,7 +2915,7 @@ export class UIRenderer { acc[type] = new RegExp(`\\[${type}:([a-f\\d-]+)\\](.*?)\\[\\/${type}\\]`, 'g'); return acc; }, {}), - doubleBracket: /\[\[(.*?)\|(.*?)\]\]/g + doubleBracket: /\[\[(.*?)\|(.*?)\]\]/g, }; const parseBio = (text) => { @@ -2923,12 +2923,23 @@ export class UIRenderer { let parsed = text; - linkTypes.forEach(type => { - parsed = parsed.replace(regexCache.wimp[type], (m, id, name) => `${name}`); - parsed = parsed.replace(regexCache.legacy[type], (m, id, name) => `${name}`); + linkTypes.forEach((type) => { + parsed = parsed.replace( + regexCache.wimp[type], + (_m, id, name) => + `${name}` + ); + parsed = parsed.replace( + regexCache.legacy[type], + (_m, id, name) => + `${name}` + ); }); - parsed = parsed.replace(regexCache.doubleBracket, (m, name, id) => `${name}`); + parsed = parsed.replace( + regexCache.doubleBracket, + (_m, name, id) => `${name}` + ); return parsed.replace(/\n/g, '
'); }; @@ -2937,14 +2948,14 @@ export class UIRenderer { const stripBioTags = (text) => { if (!text) return ''; let clean = text; - linkTypes.forEach(type => { + linkTypes.forEach((type) => { // [wimpLink artistId="..."]Name[/wimpLink] -> Name - clean = clean.replace(regexCache.wimp[type], (m, id, name) => name); + clean = clean.replace(regexCache.wimp[type], (_m, _id, name) => name); // [artist:...]Name[/artist] -> Name - clean = clean.replace(regexCache.legacy[type], (m, id, name) => name); + clean = clean.replace(regexCache.legacy[type], (_m, _id, name) => name); }); // [[Name|ID]] -> Name - clean = clean.replace(regexCache.doubleBracket, (m, name, id) => name); + clean = clean.replace(regexCache.doubleBracket, (_m, name, _id) => name); return clean; }; @@ -2984,18 +2995,22 @@ export class UIRenderer { // Ensure links are clickable by attaching the listener to the modal body const modalBody = modal.querySelector('.modal-body'); - modalBody.addEventListener('click', (e) => { - const link = e.target.closest('.bio-link'); - if (link) { - e.preventDefault(); - e.stopPropagation(); - const { type, id } = link.dataset; - if (type && id) { - modal.remove(); - navigate(`/${type}/t/${id}`); + modalBody.addEventListener( + 'click', + (e) => { + const link = e.target.closest('.bio-link'); + if (link) { + e.preventDefault(); + e.stopPropagation(); + const { type, id } = link.dataset; + if (type && id) { + modal.remove(); + navigate(`/${type}/t/${id}`); + } } - } - }, true); // Use capture phase to ensure it's hit + }, + true + ); // Use capture phase to ensure it's hit }; const renderBioPreview = (bio) => { @@ -3005,7 +3020,7 @@ export class UIRenderer { const cleanText = stripBioTags(text); const isLong = cleanText.length > 200; const previewText = isLong ? cleanText.substring(0, 200).trim() + '...' : cleanText; - + bioEl.innerHTML = previewText.replace(/\n/g, '
'); bioEl.style.display = 'block'; bioEl.style.webkitLineClamp = 'unset';