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 = `
-