refactor(ui): improve accessibility and security in "In Your Library" section
Replace h2 toggle with semantic button and aria-expanded attribute, switch from style.display to hidden attribute for visibility control, use DOM methods instead of innerHTML for source labels and modal options to prevent XSS, improve artist matching with ID-based lookup, and clean up event listeners before re-attaching to prevent leaks.
This commit is contained in:
parent
52d5166363
commit
43f816ad25
4 changed files with 59 additions and 28 deletions
14
index.html
14
index.html
|
|
@ -3297,13 +3297,15 @@
|
||||||
<div class="track-list" id="artist-detail-tracks"></div>
|
<div class="track-list" id="artist-detail-tracks"></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="content-section" id="artist-section-in-library" style="display: none">
|
<section class="content-section" id="artist-section-in-library" style="display: none">
|
||||||
<h2 class="section-title in-library-toggle" id="in-library-toggle" style="cursor: pointer; display: flex; align-items: center; gap: 0.5rem; user-select: none;">
|
<h2 class="section-title">
|
||||||
In Your Library
|
<button id="in-library-toggle" aria-expanded="false" aria-controls="artist-detail-in-library" style="background: none; border: none; color: inherit; font: inherit; cursor: pointer; display: flex; align-items: center; gap: 0.5rem; padding: 0; user-select: none;">
|
||||||
<svg id="in-library-chevron" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition: transform 0.25s ease; flex-shrink: 0;">
|
In Your Library
|
||||||
<polyline points="9 18 15 12 9 6"></polyline>
|
<svg id="in-library-chevron" xmlns="http://www.w3.org/2000/svg" width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" style="transition: transform 0.25s ease; flex-shrink: 0;">
|
||||||
</svg>
|
<polyline points="9 18 15 12 9 6"></polyline>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</h2>
|
</h2>
|
||||||
<div class="track-list" id="artist-detail-in-library" style="display: none;"></div>
|
<div class="track-list" id="artist-detail-in-library" hidden></div>
|
||||||
</section>
|
</section>
|
||||||
<section class="content-section">
|
<section class="content-section">
|
||||||
<h2 class="section-title">Albums</h2>
|
<h2 class="section-title">Albums</h2>
|
||||||
|
|
|
||||||
2
js/db.js
2
js/db.js
|
|
@ -585,6 +585,7 @@ export class MusicDatabase {
|
||||||
|
|
||||||
// TRIGGER SYNC
|
// TRIGGER SYNC
|
||||||
this._dispatchPlaylistSync('create', playlist);
|
this._dispatchPlaylistSync('create', playlist);
|
||||||
|
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
||||||
|
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
@ -657,6 +658,7 @@ export class MusicDatabase {
|
||||||
|
|
||||||
// TRIGGER SYNC (but for deleting)
|
// TRIGGER SYNC (but for deleting)
|
||||||
this._dispatchPlaylistSync('delete', { id: playlistId });
|
this._dispatchPlaylistSync('delete', { id: playlistId });
|
||||||
|
window.dispatchEvent(new CustomEvent('playlist-tracks-changed'));
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPlaylist(playlistId) {
|
async getPlaylist(playlistId) {
|
||||||
|
|
|
||||||
63
js/ui.js
63
js/ui.js
|
|
@ -3953,11 +3953,13 @@ export class UIRenderer {
|
||||||
if (inLibrarySection) inLibrarySection.style.display = 'none';
|
if (inLibrarySection) inLibrarySection.style.display = 'none';
|
||||||
if (inLibraryContainer) {
|
if (inLibraryContainer) {
|
||||||
inLibraryContainer.innerHTML = '';
|
inLibraryContainer.innerHTML = '';
|
||||||
inLibraryContainer.style.display = 'none';
|
inLibraryContainer.hidden = true;
|
||||||
}
|
}
|
||||||
// Reset chevron state
|
// Reset chevron and toggle state
|
||||||
const chevronEl = document.getElementById('in-library-chevron');
|
const chevronEl = document.getElementById('in-library-chevron');
|
||||||
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
|
if (chevronEl) chevronEl.style.transform = 'rotate(0deg)';
|
||||||
|
const toggleBtn = document.getElementById('in-library-toggle');
|
||||||
|
if (toggleBtn) toggleBtn.setAttribute('aria-expanded', 'false');
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const artist = await this.api.getArtist(artistId, provider);
|
const artist = await this.api.getArtist(artistId, provider);
|
||||||
|
|
@ -4199,12 +4201,16 @@ export class UIRenderer {
|
||||||
const isTrackByArtist = (track) => {
|
const isTrackByArtist = (track) => {
|
||||||
if (track.artists && Array.isArray(track.artists)) {
|
if (track.artists && Array.isArray(track.artists)) {
|
||||||
return track.artists.some(
|
return track.artists.some(
|
||||||
(a) => a && a.name && a.name.toLowerCase() === artistNameLower
|
(a) => a && ((artist.id && a.id === artist.id) || (a.name && a.name.toLowerCase() === artistNameLower))
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (track.artist) {
|
if (track.artist) {
|
||||||
const artistStr = typeof track.artist === 'string' ? track.artist : track.artist.name;
|
if (typeof track.artist === 'object') {
|
||||||
if (artistStr && artistStr.toLowerCase() === artistNameLower) return true;
|
if (artist.id && track.artist.id === artist.id) return true;
|
||||||
|
if (track.artist.name && track.artist.name.toLowerCase() === artistNameLower) return true;
|
||||||
|
} else if (typeof track.artist === 'string') {
|
||||||
|
if (track.artist.toLowerCase() === artistNameLower) return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
return false;
|
return false;
|
||||||
};
|
};
|
||||||
|
|
@ -4285,18 +4291,27 @@ export class UIRenderer {
|
||||||
const sourceSpan = document.createElement('span');
|
const sourceSpan = document.createElement('span');
|
||||||
sourceSpan.className = 'library-source';
|
sourceSpan.className = 'library-source';
|
||||||
|
|
||||||
|
const labelSpan = document.createElement('span');
|
||||||
|
labelSpan.className = 'library-source-label';
|
||||||
|
labelSpan.textContent = '· Source:\u00a0';
|
||||||
|
|
||||||
|
const linkSpan = document.createElement('span');
|
||||||
|
linkSpan.className = 'library-source-link';
|
||||||
|
|
||||||
|
sourceSpan.style.cursor = 'pointer';
|
||||||
|
sourceSpan.appendChild(labelSpan);
|
||||||
|
sourceSpan.appendChild(linkSpan);
|
||||||
|
|
||||||
if (sources.length === 1) {
|
if (sources.length === 1) {
|
||||||
const srcLabel = sources[0].label.length > 15 ? sources[0].label.slice(0, 15) + '…' : sources[0].label;
|
const srcLabel = sources[0].label.length > 15 ? sources[0].label.slice(0, 15) + '…' : sources[0].label;
|
||||||
sourceSpan.innerHTML = `<span class="library-source-label">· Source: </span><span class="library-source-link">${srcLabel}</span>`;
|
linkSpan.textContent = srcLabel;
|
||||||
sourceSpan.style.cursor = 'pointer';
|
|
||||||
sourceSpan.addEventListener('click', (e) => {
|
sourceSpan.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
navigate(sources[0].href);
|
navigate(sources[0].href);
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
sourceSpan.innerHTML = `<span class="library-source-label">· Source: </span><span class="library-source-link">Multiple Playlists</span>`;
|
linkSpan.textContent = 'Multiple Playlists';
|
||||||
sourceSpan.style.cursor = 'pointer';
|
|
||||||
sourceSpan.addEventListener('click', (e) => {
|
sourceSpan.addEventListener('click', (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
|
|
@ -4306,11 +4321,16 @@ export class UIRenderer {
|
||||||
const cancelBtn = document.getElementById('goto-playlist-cancel');
|
const cancelBtn = document.getElementById('goto-playlist-cancel');
|
||||||
const overlay = modal.querySelector('.modal-overlay');
|
const overlay = modal.querySelector('.modal-overlay');
|
||||||
|
|
||||||
list.innerHTML = sources.map((s) =>
|
list.innerHTML = '';
|
||||||
`<div class="modal-option" data-href="${s.href}">
|
sources.forEach((s) => {
|
||||||
<span>${s.label}</span>
|
const option = document.createElement('div');
|
||||||
</div>`
|
option.className = 'modal-option';
|
||||||
).join('');
|
option.dataset.href = s.href;
|
||||||
|
const span = document.createElement('span');
|
||||||
|
span.textContent = s.label;
|
||||||
|
option.appendChild(span);
|
||||||
|
list.appendChild(option);
|
||||||
|
});
|
||||||
|
|
||||||
const closeModal = () => {
|
const closeModal = () => {
|
||||||
modal.classList.remove('active');
|
modal.classList.remove('active');
|
||||||
|
|
@ -4357,7 +4377,7 @@ export class UIRenderer {
|
||||||
|
|
||||||
// Initial load
|
// Initial load
|
||||||
refreshInLibrary().then(() => {
|
refreshInLibrary().then(() => {
|
||||||
inLibraryContainer.style.display = 'none';
|
inLibraryContainer.hidden = true;
|
||||||
});
|
});
|
||||||
|
|
||||||
// Setup chevron toggle (once)
|
// Setup chevron toggle (once)
|
||||||
|
|
@ -4365,8 +4385,9 @@ export class UIRenderer {
|
||||||
const chevron = document.getElementById('in-library-chevron');
|
const chevron = document.getElementById('in-library-chevron');
|
||||||
if (toggle) {
|
if (toggle) {
|
||||||
toggle.onclick = () => {
|
toggle.onclick = () => {
|
||||||
const isOpen = inLibraryContainer.style.display !== 'none';
|
const isOpen = !inLibraryContainer.hidden;
|
||||||
inLibraryContainer.style.display = isOpen ? 'none' : '';
|
inLibraryContainer.hidden = isOpen;
|
||||||
|
toggle.setAttribute('aria-expanded', String(!isOpen));
|
||||||
if (chevron) {
|
if (chevron) {
|
||||||
chevron.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
|
chevron.style.transform = isOpen ? 'rotate(0deg)' : 'rotate(90deg)';
|
||||||
}
|
}
|
||||||
|
|
@ -4379,15 +4400,17 @@ export class UIRenderer {
|
||||||
clearTimeout(refreshTimeout);
|
clearTimeout(refreshTimeout);
|
||||||
refreshTimeout = setTimeout(() => refreshInLibrary(), 300);
|
refreshTimeout = setTimeout(() => refreshInLibrary(), 300);
|
||||||
};
|
};
|
||||||
window.addEventListener('favorites-changed', debouncedRefresh);
|
|
||||||
window.addEventListener('playlist-tracks-changed', debouncedRefresh);
|
|
||||||
|
|
||||||
// Cleanup listeners when navigating away
|
// Cleanup previous listeners before attaching new ones
|
||||||
const cleanupOnNav = () => {
|
const cleanupOnNav = () => {
|
||||||
window.removeEventListener('favorites-changed', debouncedRefresh);
|
window.removeEventListener('favorites-changed', debouncedRefresh);
|
||||||
window.removeEventListener('playlist-tracks-changed', debouncedRefresh);
|
window.removeEventListener('playlist-tracks-changed', debouncedRefresh);
|
||||||
window.removeEventListener('popstate', cleanupOnNav);
|
window.removeEventListener('popstate', cleanupOnNav);
|
||||||
};
|
};
|
||||||
|
cleanupOnNav();
|
||||||
|
|
||||||
|
window.addEventListener('favorites-changed', debouncedRefresh);
|
||||||
|
window.addEventListener('playlist-tracks-changed', debouncedRefresh);
|
||||||
window.addEventListener('popstate', cleanupOnNav, { once: true });
|
window.addEventListener('popstate', cleanupOnNav, { once: true });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1994,6 +1994,10 @@ input[type='search']::-webkit-search-cancel-button {
|
||||||
gap: 2px var(--spacing-xl);
|
gap: 2px var(--spacing-xl);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#artist-detail-in-library[hidden] {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.library-source {
|
.library-source {
|
||||||
color: var(--muted-foreground);
|
color: var(--muted-foreground);
|
||||||
font-size: inherit;
|
font-size: inherit;
|
||||||
|
|
@ -2365,8 +2369,8 @@ input[type='search']::-webkit-search-cancel-button {
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 1rem;
|
gap: 1rem;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
overflow-wrap: break-word;
|
overflow-wrap: anywhere;
|
||||||
word-break: break-word;
|
word-break: normal;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue