refactor, better compact cards

This commit is contained in:
Julien Maille 2026-01-05 21:47:34 +01:00
parent a880fe7777
commit 72d27ef7fe
2 changed files with 218 additions and 148 deletions

219
js/ui.js
View file

@ -202,55 +202,80 @@ export class UIRenderer {
`;
}
createPlaylistCardHTML(playlist) {
const imageId = playlist.squareImage || playlist.image || playlist.uuid; // Fallback or use a specific cover getter if needed
const isCompact = cardSettings.isCompactAlbum();
createBaseCardHTML({ type, id, href, title, subtitle, imageHTML, actionButtonsHTML, isCompact, extraClasses = '' }) {
const playBtnHTML = type !== 'artist' ? `
<button class="play-btn card-play-btn" data-action="play-card" data-type="${type}" data-id="${id}" title="Play">
${SVG_PLAY}
</button>
` : '';
const cardContent = type === 'artist'
? `<h4 class="card-title">${title}</h4>`
: `<div class="card-info">
<h4 class="card-title">${title}</h4>
<p class="card-subtitle">${subtitle}</p>
</div>`;
// In compact mode, move the play button outside the wrapper to position it on the right side of the card
const buttonsInWrapper = !isCompact ? playBtnHTML : '';
const buttonsOutside = isCompact ? playBtnHTML : '';
return `
<div class="card ${isCompact ? 'compact' : ''}" data-playlist-id="${playlist.uuid}" data-href="#playlist/${playlist.uuid}" style="cursor: pointer;">
<div class="card ${extraClasses} ${isCompact ? 'compact' : ''}" data-${type}-id="${id}" data-href="${href}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(imageId)}" alt="${playlist.title}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="playlist" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
<button class="play-btn card-play-btn" data-action="play-card" data-type="playlist" data-id="${playlist.uuid}" title="Play">
${SVG_PLAY}
</button>
</div>
<div class="card-info">
<h4 class="card-title">${playlist.title}</h4>
<p class="card-subtitle">${playlist.numberOfTracks || 0} tracks</p>
${imageHTML}
${actionButtonsHTML}
${buttonsInWrapper}
</div>
${cardContent}
${buttonsOutside}
</div>
`;
}
createPlaylistCardHTML(playlist) {
const imageId = playlist.squareImage || playlist.image || playlist.uuid;
const isCompact = cardSettings.isCompactAlbum();
return this.createBaseCardHTML({
type: 'playlist',
id: playlist.uuid,
href: `#playlist/${playlist.uuid}`,
title: playlist.title,
subtitle: `${playlist.numberOfTracks || 0} tracks`,
imageHTML: `<img src="${this.api.getCoverUrl(imageId)}" alt="${playlist.title}" class="card-image" loading="lazy">`,
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="playlist" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact
});
}
createMixCardHTML(mix) {
const imageSrc = mix.cover || 'assets/appicon.png';
const description = mix.subTitle || mix.description || '';
const isCompact = cardSettings.isCompactAlbum();
return `
<div class="card ${isCompact ? 'compact' : ''}" data-mix-id="${mix.id}" data-href="#mix/${mix.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img src="${imageSrc}" alt="${mix.title}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="mix" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
<button class="play-btn card-play-btn" data-action="play-card" data-type="mix" data-id="${mix.id}" title="Play">
${SVG_PLAY}
</button>
</div>
<div class="card-info">
<h4 class="card-title">${mix.title}</h4>
<p class="card-subtitle">${description}</p>
</div>
</div>
`;
return this.createBaseCardHTML({
type: 'mix',
id: mix.id,
href: `#mix/${mix.id}`,
title: mix.title,
subtitle: description,
imageHTML: `<img src="${imageSrc}" alt="${mix.title}" class="card-image" loading="lazy">`,
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="mix" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact
});
}
createUserPlaylistCardHTML(playlist) {
let imageHTML = '';
if (playlist.cover) {
imageHTML = `<img src="${playlist.cover}" alt="${playlist.name}" class="card-image" loading="lazy">`;
} else {
@ -273,7 +298,6 @@ export class UIRenderer {
const count = Math.min(uniqueCovers.length, 4);
const itemsClass = count < 4 ? `items-${count}` : '';
const covers = uniqueCovers.slice(0, 4);
imageHTML = `
<div class="card-image card-collage ${itemsClass}">
${covers.map(cover => `<img src="${this.api.getCoverUrl(cover)}" alt="" loading="lazy">`).join('')}
@ -288,35 +312,34 @@ export class UIRenderer {
const isCompact = cardSettings.isCompactAlbum();
return `
<div class="card user-playlist ${isCompact ? 'compact' : ''}" data-playlist-id="${playlist.id}" data-href="#userplaylist/${playlist.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
${imageHTML}
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="delete-playlist-btn" data-action="delete-playlist" title="Delete Playlist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
<button class="play-btn card-play-btn" data-action="play-card" data-type="user-playlist" data-id="${playlist.id}" title="Play">
${SVG_PLAY}
</button>
</div>
<div class="card-info">
<h4 class="card-title">${playlist.name}</h4>
<p class="card-subtitle">${playlist.tracks ? playlist.tracks.length : (playlist.numberOfTracks || 0)} tracks</p>
</div>
</div>
`;
return this.createBaseCardHTML({
type: 'user-playlist', // Note: data-type logic in base might need adjustment if it uses this for buttons.
// Actually Base uses type for data attributes. play-card uses data-type="user-playlist" which is correct.
id: playlist.id,
href: `#userplaylist/${playlist.id}`,
title: playlist.name,
subtitle: `${playlist.tracks ? playlist.tracks.length : (playlist.numberOfTracks || 0)} tracks`,
imageHTML: imageHTML,
actionButtonsHTML: `
<button class="edit-playlist-btn" data-action="edit-playlist" title="Edit Playlist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/>
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/>
</svg>
</button>
<button class="delete-playlist-btn" data-action="delete-playlist" title="Delete Playlist">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M3 6h18"/>
<path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"/>
<path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"/>
<line x1="10" y1="11" x2="10" y2="17"/>
<line x1="14" y1="11" x2="14" y2="17"/>
</svg>
</button>
`,
isCompact,
extraClasses: 'user-playlist'
});
}
createAlbumCardHTML(album) {
@ -324,53 +347,49 @@ export class UIRenderer {
let yearDisplay = '';
if (album.releaseDate) {
const date = new Date(album.releaseDate);
if (!isNaN(date.getTime())) {
yearDisplay = `${date.getFullYear()}`;
}
if (!isNaN(date.getTime())) yearDisplay = `${date.getFullYear()}`;
}
let typeLabel = '';
if (album.type === 'EP') {
typeLabel = ' • EP';
} else if (album.type === 'SINGLE') {
typeLabel = ' • Single';
}
if (album.type === 'EP') typeLabel = ' • EP';
else if (album.type === 'SINGLE') typeLabel = ' • Single';
const isCompact = cardSettings.isCompactAlbum();
return `
<div class="card ${isCompact ? 'compact' : ''}" data-album-id="${album.id}" data-href="#album/${album.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img src="${this.api.getCoverUrl(album.cover)}" alt="${album.title}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
<button class="play-btn card-play-btn" data-action="play-card" data-type="album" data-id="${album.id}" title="Play">
${SVG_PLAY}
</button>
</div>
<div class="card-info">
<h4 class="card-title">${album.title} ${explicitBadge}</h4>
<p class="card-subtitle">${album.artist?.name ?? ''}</p>
<p class="card-subtitle">${yearDisplay}${typeLabel}</p>
</div>
</div>
`;
return this.createBaseCardHTML({
type: 'album',
id: album.id,
href: `#album/${album.id}`,
title: `${album.title} ${explicitBadge}`,
subtitle: `${album.artist?.name ?? ''}${yearDisplay}${typeLabel}`,
imageHTML: `<img src="${this.api.getCoverUrl(album.cover)}" alt="${album.title}" class="card-image" loading="lazy">`,
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="album" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact
});
}
createArtistCardHTML(artist) {
const isCompact = cardSettings.isCompactArtist();
return `
<div class="card artist ${isCompact ? 'compact' : ''}" data-artist-id="${artist.id}" data-href="#artist/${artist.id}" style="cursor: pointer;">
<div class="card-image-wrapper">
<img src="${this.api.getArtistPictureUrl(artist.picture)}" alt="${artist.name}" class="card-image" loading="lazy">
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
</div>
<h4 class="card-title">${artist.name}</h4>
</div>
`;
return this.createBaseCardHTML({
type: 'artist',
id: artist.id,
href: `#artist/${artist.id}`,
title: artist.name,
subtitle: '',
imageHTML: `<img src="${this.api.getArtistPictureUrl(artist.picture)}" alt="${artist.name}" class="card-image" loading="lazy">`,
actionButtonsHTML: `
<button class="like-btn card-like-btn" data-action="toggle-like" data-type="artist" title="Add to Liked">
${this.createHeartIcon(false)}
</button>
`,
isCompact,
extraClasses: 'artist'
});
}
createSkeletonTrack(showCover = false) {

View file

@ -1,4 +1,3 @@
:root {
color-scheme: light dark;
--spacing-xs: 0.4rem;
@ -133,11 +132,11 @@
--secondary-foreground: #9399b2;
--muted: #313244;
--muted-foreground: #a6adc8;
--border: #313244;
--border: #313244;
--input: #45475a;
--ring: #89b4fa;
--highlight: #89b4fa;
--highlight-rgb: #b4befe;
--highlight: #89b4fa;
--highlight-rgb: #b4befe;
--active-highlight: #b4befe;
--explicit-badge: #f9e2af;
}
@ -154,11 +153,11 @@
--secondary-foreground: #6e738d;
--muted: #363a4f;
--muted-foreground: #a5adcb;
--border: #363a4f;
--border: #363a4f;
--input: #494d64;
--ring: #8aadf4;
--highlight: #8aadf4;
--highlight-rgb: #b7bdf8;
--highlight: #8aadf4;
--highlight-rgb: #b7bdf8;
--active-highlight: #b7bdf8;
--explicit-badge: #eed49f;
}
@ -166,7 +165,7 @@
:root[data-theme="frappe"] {
color-scheme: dark;
--background: #303446;
--foreground:#c6d0f5;
--foreground: #c6d0f5;
--card: #414559;
--card-foreground: #949cbb;
--primary: #8caaee;
@ -175,11 +174,11 @@
--secondary-foreground: #a5adce;
--muted: #414559;
--muted-foreground: #a5adce;
--border: #414559;
--border: #414559;
--input: #45475a;
--ring: #8caaee;
--highlight: #8caaee;
--highlight-rgb: #babbf1;
--highlight: #8caaee;
--highlight-rgb: #babbf1;
--active-highlight: #babbf1;
--explicit-badge: #e5c890;
}
@ -332,11 +331,11 @@ kbd {
background-repeat: no-repeat;
opacity: 0;
transition: opacity 0.5s ease-in-out;
/* Fade out at the bottom */
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0.8) 40%, rgba(0,0,0,0) 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0.8) 40%, rgba(0,0,0,0) 100%);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.8) 40%, rgba(0, 0, 0, 0) 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0.8) 40%, rgba(0, 0, 0, 0) 100%);
/* Blur effect */
filter: var(--cover-filter);
pointer-events: none;
@ -348,8 +347,8 @@ kbd {
/* Light mode adjustments */
:root[data-theme="light"] #page-background {
mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0,0,0,1) 0%, rgba(0,0,0,0) 100%);
mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
-webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, 1) 0%, rgba(0, 0, 0, 0) 100%);
}
.now-playing-bar {
@ -529,6 +528,7 @@ body.has-page-background .track-item:hover {
opacity: 0;
transform: translateY(4px);
}
to {
opacity: 1;
transform: translateY(0);
@ -540,6 +540,7 @@ body.has-page-background .track-item:hover {
opacity: 0;
transform: scale(0.95);
}
to {
opacity: 1;
transform: scale(1);
@ -551,6 +552,7 @@ body.has-page-background .track-item:hover {
opacity: 0;
transform: translateX(100%);
}
to {
opacity: 1;
transform: translateX(0);
@ -562,6 +564,7 @@ body.has-page-background .track-item:hover {
opacity: 1;
transform: translateX(0);
}
to {
opacity: 0;
transform: translateX(100%);
@ -569,17 +572,31 @@ body.has-page-background .track-item:hover {
}
@keyframes spin {
to { transform: rotate(360deg); }
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.5;
}
}
@keyframes skeleton-loading {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.animate-spin {
@ -1623,7 +1640,7 @@ input:checked + .slider::before {
width: 100%;
height: 100%;
/* Remove fixed padding to allow flex centering to work within the overlay's padded box */
padding: 1rem;
padding: 1rem;
position: relative;
}
@ -1655,7 +1672,7 @@ input:checked + .slider::before {
max-width: 80vw;
max-height: 60vh;
border-radius: var(--radius);
box-shadow: 0 20px 50px rgba(0,0,0,0.5);
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.5);
object-fit: contain;
margin-bottom: 2rem;
z-index: 1;
@ -1842,6 +1859,11 @@ input:checked + .slider::before {
.queue-track-item .queue-remove-btn {
opacity: 1;
}
/* Hide like button on compact cards on mobile */
.card.compact .card-like-btn {
display: none !important;
}
}
.queue-track-item .queue-remove-btn:hover {
@ -1864,12 +1886,10 @@ input:checked + .slider::before {
}
.skeleton {
background: linear-gradient(
90deg,
var(--secondary) 0%,
var(--muted) 50%,
var(--secondary) 100%
);
background: linear-gradient(90deg,
var(--secondary) 0%,
var(--muted) 50%,
var(--secondary) 100%);
background-size: 200% 100%;
animation: skeleton-loading 1.5s ease-in-out infinite;
border-radius: var(--radius);
@ -3007,6 +3027,7 @@ input:checked + .slider::before {
padding-top: max(var(--spacing-md), env(safe-area-inset-top));
}
}
/* Side Panels (Lyrics & Queue) */
:root {
--player-bar-height-desktop: 90px;
@ -3178,14 +3199,46 @@ input:checked + .slider::before {
color: var(--muted-foreground);
}
/* Compact Card Play Button */
.card.compact .card-play-btn {
right: 0.5rem;
top: 25%;
width: 36px !important;
height: 36px !important;
background-color: var(--primary) !important;
color: var(--primary-foreground) !important;
box-shadow: var(--shadow-md);
transition: opacity 0.2s ease;
}
/* Hide play button initially on desktop (hover capable), show on hover */
@media (hover: hover) {
.card.compact .card-play-btn {
opacity: 0;
}
.card.compact:hover .card-play-btn {
opacity: 1;
}
}
/* Adjust like button for compact size */
.card.compact .card-like-btn {
display: none !important;
width: 24px !important;
height: 24px !important;
top: 0;
right: 0;
}
.card.compact:hover .card-like-btn {
opacity: 1;
}
.card.compact.user-playlist .edit-playlist-btn,
.card.compact.user-playlist .delete-playlist-btn {
display: none !important; /* Hide edit/delete on compact view to prevent covering image */
}
/* Mobile adjustments */
@media (max-width: 768px) {
.side-panel {
@ -3270,6 +3323,7 @@ input:checked + .slider::before {
cursor: pointer;
transition: color var(--transition);
}
.now-playing-bar .artist .artist-link:hover {
color: var(--highlight);
text-decoration: underline;
@ -3384,10 +3438,10 @@ img:not([src]), img[src=''] {
}
.search-bar {
display: flex;
align-items: center;
width: 80%;
max-width: 100%;
display: flex;
align-items: center;
width: 80%;
max-width: 100%;
}
@ -3445,24 +3499,21 @@ img:not([src]), img[src=''] {
}
}
#playlist-modal {
opacity: 1;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 0.1s;
opacity: 1;
animation-name: fadeInOpacity;
animation-iteration-count: 1;
animation-timing-function: ease-in;
animation-duration: 0.1s;
}
@keyframes fadeInOpacity {
0% {
opacity: 0;
}
0% {
opacity: 0;
}
100% {
opacity: 1;
}
opacity: 1;
}
}
/* fuck this chud ass shit bro */