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

View file

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