Redesign SoulSync Discover cards to grid layout matching Server Playlists
Some checks failed
Compile the app and run tests / sanity-check (push) Has been cancelled

The current cards are full-width horizontal rows, which makes the tab feel
dense once a few playlists accumulate. Rework to a grid of compact cards
that match the Server Playlists visual language already used on the same
Sync page, so the two surfaces feel like the same app.

Changes (all visual — no impact on JohnBaumb's sync/poll logic):

- `.discover-sync-grid` wrapper renders cards as `repeat(auto-fill,
  minmax(240px, 1fr))`. 4-6 cards per row at desktop width.
- Per-card hue derived deterministically from playlist type so each card
  has a consistent color identity across reloads. Drives the icon tint,
  source badge color, toggle color, action-button gradient, and a soft
  radial glow in the top-right corner.
- Top row: 40x40 icon chip + source badge ("DEEZER" / "SPOTIFY") pushed
  to the top-right (mirrors the chevron placement on Server Playlists).
- Body: playlist name (15px bold, 2-line clamp), track count + status
  pill on one line, last-synced subtle line below.
- Compact toggles in a small bordered panel, stacked rows of
  label+switch. Smaller 34x18 switch matches the denser card size.
- Full-width gradient action button ("Sync Now") at the bottom.
- Hover: card lifts 2px, glow + border intensify.

Deliberately kept purely visual to avoid touching JohnBaumb's logic:
- Button content stays flat text ("Sync Now" / "Syncing...") so his
  `btn.textContent = ...` poll updates still work unchanged.
- Status text format matches the strings his sync-poll code writes
  ("Synced 10/15" / "Synced" / "Not synced" / "Syncing..." / "Failed"),
  so initial render and post-sync updates stay consistent.
- All ids and state-managed class hooks preserved: `discover-sync-card-${type}`,
  `discover-sync-btn-${type}`, `.discover-sync-status`, the
  syncing/synced/not-synced modifier classes, and the
  `discover-sync-card-highlight` deep-link animation class.

Staging branch for review — not targeting dev directly. JohnBaumb can
cherry-pick onto his PR branch if he wants the redesign, or skip it.
This commit is contained in:
Broque Thomas 2026-04-24 11:21:49 -07:00
parent 97b0ee960a
commit e40eb213b4
2 changed files with 243 additions and 139 deletions

View file

@ -9077,78 +9077,104 @@ async function loadDiscoverSyncPlaylists() {
}
}
// Grid container lazy-init — we wrap cards in a .discover-sync-grid div so
// the vertical scroll container holds a proper grid. Only one grid per
// container; subsequent calls append to the existing grid.
function _getOrCreateDiscoverSyncGrid(container) {
let grid = container.querySelector(':scope > .discover-sync-grid');
if (!grid) {
grid = document.createElement('div');
grid.className = 'discover-sync-grid';
container.appendChild(grid);
}
return grid;
}
// Deterministic hue per playlist type — same playlist always gets the same
// gradient-glow color across reloads. Mirrors the i*37+200 formula used by
// server-pl-card for color-wheel spread.
function _discoverSyncHue(playlistType) {
let hash = 0;
const s = String(playlistType || '');
for (let i = 0; i < s.length; i++) hash = (hash * 31 + s.charCodeAt(i)) | 0;
return ((hash % 360) + 360) % 360;
}
function renderDiscoverSyncCard(playlist, container, sourceLabel) {
const grid = _getOrCreateDiscoverSyncGrid(container);
const card = document.createElement('div');
const isEmpty = playlist.track_count === 0;
card.className = `discover-sync-card${isEmpty ? ' discover-sync-card-empty' : ''}`;
card.id = `discover-sync-card-${playlist.type}`;
card.style.setProperty('--card-hue', _discoverSyncHue(playlist.type));
const lastSyncedText = playlist.last_synced
? `Last synced ${timeAgo(playlist.last_synced)}`
? `Synced ${timeAgo(playlist.last_synced)}`
: 'Never synced';
const statusClass = playlist.sync_status === 'syncing' ? 'syncing' :
playlist.sync_status === 'synced' ? 'synced' : 'not-synced';
// Text format mirrors the strings the sync-poll code writes back into
// this element on transitions (see pollDiscoverBatchFromTab) — keeping
// them aligned means the user sees consistent text across the
// render → syncing → synced lifecycle.
let statusText = playlist.sync_status === 'syncing' ? 'Syncing...' :
playlist.sync_status === 'synced' ? 'Synced' : 'Not synced';
// Show matched/total counts if available (only when matched > 0, meaning completion was recorded)
if (playlist.sync_status === 'synced' && playlist.matched_tracks > 0 && playlist.total_sync_tracks > 0) {
statusText = `Synced ${playlist.matched_tracks}/${playlist.total_sync_tracks}`;
}
const trackLabel = isEmpty ? 'No tracks yet' : `${playlist.track_count} tracks`;
const safePlaylistName = (playlist.name || '').replace(/'/g, "\\'");
card.innerHTML = `
<div class="discover-sync-card-icon">${playlist.icon}</div>
<div class="discover-sync-card-info">
<div class="discover-sync-card-name">${playlist.name}
<span class="discover-sync-card-meta-inline">
<span class="discover-sync-source-badge">${sourceLabel || 'unknown'}</span>
<span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-track-count">${trackLabel}</span>
<span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-status ${statusClass}">${statusText}</span>
<span class="discover-sync-separator">\u00b7</span>
<span class="discover-sync-last-synced">${lastSyncedText}</span>
</span>
</div>
<div class="discover-sync-card-glow"></div>
<div class="discover-sync-card-top">
<div class="discover-sync-card-icon">${playlist.icon || '🎵'}</div>
<div class="discover-sync-source-badge">${sourceLabel || 'unknown'}</div>
</div>
<div class="discover-sync-card-actions">
<div class="discover-sync-toggle-wrapper" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}">
<label class="discover-sync-toggle-label">Keep updated</label>
<label class="discover-sync-toggle">
<div class="discover-sync-card-body">
<div class="discover-sync-card-name">${playlist.name}</div>
<div class="discover-sync-card-meta">
<span class="discover-sync-track-count">${trackLabel}</span>
<span class="discover-sync-status ${statusClass}">${statusText}</span>
</div>
<div class="discover-sync-last-synced">${lastSyncedText}</div>
</div>
<div class="discover-sync-card-toggles">
<label class="discover-sync-toggle-row" title="${isEmpty ? 'No tracks available — visit Discover first' : 'Keep this playlist updated automatically'}">
<span class="discover-sync-toggle-label">Keep updated</span>
<span class="discover-sync-toggle">
<input type="checkbox" ${playlist.auto_update ? 'checked' : ''} ${isEmpty ? 'disabled' : ''}
onchange="toggleDiscoverAutoUpdate('${playlist.type}', this.checked)">
<span class="discover-sync-toggle-slider"></span>
</label>
</div>
<div class="discover-sync-toggle-wrapper" title="Coming soon: download any available quality for this batch, even if it's below your global quality profile. Useful for rotating discover playlists where quantity matters more than quality.">
<label class="discover-sync-toggle-label" style="opacity:0.5">Any Quality</label>
<label class="discover-sync-toggle" style="opacity:0.5;cursor:not-allowed">
</span>
</label>
<label class="discover-sync-toggle-row disabled" title="Coming soon: download any available quality for this batch, even if it's below your global quality profile. Useful for rotating discover playlists where quantity matters more than quality.">
<span class="discover-sync-toggle-label">Any Quality</span>
<span class="discover-sync-toggle">
<input type="checkbox" id="discover-any-quality-${playlist.type}" disabled>
<span class="discover-sync-toggle-slider"></span>
</label>
</div>
<button class="discover-sync-btn" id="discover-sync-btn-${playlist.type}"
onclick="syncDiscoverPlaylistFromTab('${playlist.type}', '${playlist.name}')"
${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}>
\u27f3 Sync Now
</button>
</span>
</label>
</div>
<button class="discover-sync-card-action" id="discover-sync-btn-${playlist.type}"
onclick="event.stopPropagation(); syncDiscoverPlaylistFromTab('${playlist.type}', '${safePlaylistName}')"
${playlist.sync_status === 'syncing' || isEmpty ? 'disabled' : ''}> Sync Now</button>
`;
// Make the icon + info area clickable to view tracks
// Clicking the top (icon + badge) or body (name + meta) opens the track list.
// The Sync Now button stops propagation so it doesn't double-trigger.
if (!isEmpty) {
const clickArea = card.querySelector('.discover-sync-card-info');
const iconArea = card.querySelector('.discover-sync-card-icon');
[clickArea, iconArea].forEach(el => {
const openTracks = () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon);
card.querySelectorAll('.discover-sync-card-top, .discover-sync-card-body').forEach(el => {
el.style.cursor = 'pointer';
el.addEventListener('click', () => openDiscoverPlaylistModal(playlist.type, playlist.name, playlist.icon));
el.addEventListener('click', openTracks);
});
}
container.appendChild(card);
grid.appendChild(card);
}
async function toggleDiscoverAutoUpdate(playlistType, enabled) {

View file

@ -59593,134 +59593,197 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
line-height: 1.4;
}
/* ─────────────────────────────────────────────────────────────────────── */
/* SoulSync Discover cards — grid layout mirroring server-pl-card pattern */
/* ─────────────────────────────────────────────────────────────────────── */
.discover-sync-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 16px;
padding: 4px;
}
.discover-sync-card {
background: rgba(255, 255, 255, 0.04);
position: relative;
background: rgba(255, 255, 255, 0.03);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px;
margin: 3px 6px;
padding: 8px 14px;
border-radius: 14px;
padding: 14px 14px 12px;
display: flex;
align-items: center;
gap: 10px;
transition: all 0.25s ease;
flex-direction: column;
gap: 12px;
min-height: 260px;
overflow: hidden;
transition: transform 0.25s ease, border-color 0.25s ease, background 0.25s ease;
}
.discover-sync-card:hover {
background: rgba(255, 255, 255, 0.06);
border-color: rgba(167, 139, 250, 0.2);
transform: translateY(-2px);
background: rgba(255, 255, 255, 0.05);
border-color: hsla(var(--card-hue, 260), 80%, 70%, 0.35);
}
.discover-sync-card-glow {
position: absolute;
top: -40%;
right: -20%;
width: 160px;
height: 160px;
background: radial-gradient(circle,
hsla(var(--card-hue, 260), 80%, 60%, 0.22) 0%,
hsla(var(--card-hue, 260), 80%, 60%, 0) 70%);
pointer-events: none;
z-index: 0;
transition: opacity 0.25s ease;
}
.discover-sync-card:hover .discover-sync-card-glow {
opacity: 1.4;
}
.discover-sync-card > *:not(.discover-sync-card-glow) {
position: relative;
z-index: 1;
}
/* ── Top row: icon + source badge ── */
.discover-sync-card-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.discover-sync-card-icon {
font-size: 20px;
flex-shrink: 0;
width: 32px;
height: 32px;
font-size: 22px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
background: rgba(255, 255, 255, 0.05);
border-radius: 8px;
background: linear-gradient(135deg,
hsla(var(--card-hue, 260), 70%, 55%, 0.2),
hsla(var(--card-hue, 260), 70%, 40%, 0.1));
border: 1px solid hsla(var(--card-hue, 260), 70%, 60%, 0.2);
border-radius: 10px;
flex-shrink: 0;
}
.discover-sync-card-info {
.discover-sync-source-badge {
font-size: 9.5px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.6px;
padding: 3px 8px;
border-radius: 5px;
color: hsla(var(--card-hue, 260), 80%, 80%, 1);
background: hsla(var(--card-hue, 260), 70%, 55%, 0.12);
border: 1px solid hsla(var(--card-hue, 260), 70%, 60%, 0.25);
white-space: nowrap;
}
/* ── Body: name + meta line + last-synced ── */
.discover-sync-card-body {
display: flex;
flex-direction: column;
gap: 4px;
flex: 1;
min-width: 0;
}
.discover-sync-card-name {
font-size: 13px;
font-weight: 600;
font-size: 15px;
font-weight: 700;
color: #fff;
display: flex;
align-items: center;
gap: 10px;
flex-wrap: wrap;
}
.discover-sync-card-meta-inline {
display: inline-flex;
align-items: center;
gap: 6px;
font-size: 11px;
font-weight: 400;
color: rgba(255, 255, 255, 0.4);
}
.discover-sync-source-badge {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
padding: 1px 7px;
border-radius: 4px;
color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-rgb), 0.1);
border: 1px solid rgba(var(--accent-rgb), 0.2);
}
.discover-sync-card-desc {
display: none;
line-height: 1.25;
overflow: hidden;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
word-break: break-word;
}
.discover-sync-card-meta {
display: none;
color: rgba(255, 255, 255, 0.4);
}
.discover-sync-separator {
opacity: 0.4;
display: flex;
align-items: center;
gap: 8px;
font-size: 11.5px;
font-weight: 500;
margin-top: 2px;
}
.discover-sync-track-count {
color: rgba(255, 255, 255, 0.55);
color: rgba(255, 255, 255, 0.6);
}
.discover-sync-status {
font-weight: 500;
font-weight: 600;
padding: 2px 7px;
border-radius: 4px;
font-size: 10.5px;
letter-spacing: 0.2px;
}
.discover-sync-status.synced {
color: #22c55e;
color: #4ade80;
background: rgba(34, 197, 94, 0.12);
}
.discover-sync-status.syncing {
color: #facc15;
color: #fde047;
background: rgba(250, 204, 21, 0.14);
}
.discover-sync-status.not-synced {
color: rgba(255, 255, 255, 0.35);
color: rgba(255, 255, 255, 0.45);
background: rgba(255, 255, 255, 0.05);
}
.discover-sync-last-synced {
color: rgba(255, 255, 255, 0.35);
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
margin-top: 2px;
}
.discover-sync-card-actions {
display: flex;
align-items: center;
gap: 16px;
flex-shrink: 0;
}
.discover-sync-toggle-wrapper {
/* ── Toggles (stacked rows of label + switch) ── */
.discover-sync-card-toggles {
display: flex;
flex-direction: column;
gap: 6px;
padding: 8px 10px;
background: rgba(255, 255, 255, 0.02);
border: 1px solid rgba(255, 255, 255, 0.05);
border-radius: 8px;
}
.discover-sync-toggle-row {
display: flex;
align-items: center;
gap: 4px;
justify-content: space-between;
gap: 10px;
cursor: pointer;
min-height: 22px;
}
.discover-sync-toggle-row.disabled {
opacity: 0.45;
cursor: not-allowed;
}
.discover-sync-toggle-label {
font-size: 10.5px;
color: rgba(255, 255, 255, 0.4);
font-size: 11.5px;
font-weight: 500;
color: rgba(255, 255, 255, 0.65);
white-space: nowrap;
}
.discover-sync-toggle {
position: relative;
display: inline-block;
width: 40px;
height: 22px;
cursor: pointer;
width: 34px;
height: 18px;
flex-shrink: 0;
}
.discover-sync-toggle input {
@ -59736,56 +59799,65 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
right: 0;
bottom: 0;
background: rgba(255, 255, 255, 0.12);
border-radius: 22px;
border-radius: 18px;
transition: all 0.25s ease;
}
.discover-sync-toggle-slider::before {
content: '';
position: absolute;
width: 16px;
height: 16px;
left: 3px;
bottom: 3px;
width: 14px;
height: 14px;
left: 2px;
bottom: 2px;
background: #fff;
border-radius: 50%;
transition: all 0.25s ease;
}
.discover-sync-toggle input:checked + .discover-sync-toggle-slider {
background: #a78bfa;
background: hsla(var(--card-hue, 260), 75%, 60%, 1);
}
.discover-sync-toggle input:checked + .discover-sync-toggle-slider::before {
transform: translateX(18px);
transform: translateX(16px);
}
.discover-sync-btn {
padding: 8px 16px;
background: linear-gradient(135deg, #a78bfa, #7c3aed);
/* ── Primary action button ── */
/* Kept as a flat-text button (no nested children) because the sync-poll
code writes `btn.textContent = 'Syncing…'` / `'⟳ Sync Now'` on state
transitions any child elements would be wiped on every toggle. */
.discover-sync-card-action {
display: block;
width: 100%;
padding: 10px 14px;
background: linear-gradient(135deg,
hsla(var(--card-hue, 260), 70%, 55%, 0.85),
hsla(var(--card-hue, 260), 70%, 42%, 0.85));
color: #fff;
border: none;
border-radius: 8px;
border: 1px solid hsla(var(--card-hue, 260), 70%, 65%, 0.35);
border-radius: 10px;
font-size: 12.5px;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
transition: all 0.25s ease;
text-align: center;
transition: transform 0.2s ease, box-shadow 0.2s ease, filter 0.2s ease;
margin-top: auto;
}
.discover-sync-btn:hover:not(:disabled) {
.discover-sync-card-action:hover:not(:disabled) {
transform: translateY(-1px);
box-shadow: 0 4px 12px rgba(167, 139, 250, 0.35);
filter: brightness(1.08);
box-shadow: 0 6px 18px hsla(var(--card-hue, 260), 70%, 50%, 0.35);
}
.discover-sync-btn:disabled {
opacity: 0.4;
.discover-sync-card-action:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
box-shadow: none;
filter: grayscale(30%);
}
/* Empty-state styling */
/* ── Empty / highlight states ── */
.discover-sync-card-empty {
opacity: 0.55;
}
@ -59794,16 +59866,22 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
opacity: 0.4;
}
/* Deep-link highlight animation */
.discover-sync-card-highlight {
animation: discover-card-glow 2.5s ease-out;
}
@keyframes discover-card-glow {
0% { border-color: rgba(167, 139, 250, 0.7); box-shadow: 0 0 20px rgba(167, 139, 250, 0.3); }
100% { border-color: rgba(255, 255, 255, 0.08); box-shadow: none; }
0% {
border-color: hsla(var(--card-hue, 260), 80%, 70%, 0.9);
box-shadow: 0 0 24px hsla(var(--card-hue, 260), 80%, 60%, 0.4);
}
100% {
border-color: rgba(255, 255, 255, 0.08);
box-shadow: none;
}
}
/* ── Existing empty-state hint + source info (unchanged) ── */
.discover-sync-empty-hint {
background: rgba(255, 193, 7, 0.08);
border: 1px solid rgba(255, 193, 7, 0.25);
@ -59839,14 +59917,14 @@ body[data-artist-source="source"] #artist-detail-page #library-artist-enhance-bt
}
@media (max-width: 768px) {
.discover-sync-card {
flex-wrap: wrap;
.discover-sync-grid {
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: 12px;
}
.discover-sync-card-actions {
width: 100%;
justify-content: flex-end;
.discover-sync-card {
min-height: 240px;
padding: 12px;
}
}