From e40eb213b417bb3baa5a41eb8120645a4c370924 Mon Sep 17 00:00:00 2001 From: Broque Thomas <26755000+Nezreka@users.noreply.github.com> Date: Fri, 24 Apr 2026 11:21:49 -0700 Subject: [PATCH] Redesign SoulSync Discover cards to grid layout matching Server Playlists MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- webui/static/discover.js | 100 +++++++++----- webui/static/style.css | 282 +++++++++++++++++++++++++-------------- 2 files changed, 243 insertions(+), 139 deletions(-) diff --git a/webui/static/discover.js b/webui/static/discover.js index 64e35d9a..d1639575 100644 --- a/webui/static/discover.js +++ b/webui/static/discover.js @@ -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 = ` -
${playlist.icon}
-
-
${playlist.name} - - ${sourceLabel || 'unknown'} - \u00b7 - ${trackLabel} - \u00b7 - ${statusText} - \u00b7 - ${lastSyncedText} - -
+
+
+
${playlist.icon || '🎵'}
+
${sourceLabel || 'unknown'}
-
-
- -
+ `; - // 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) { diff --git a/webui/static/style.css b/webui/static/style.css index 250f54af..f0c6ef6d 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; } }