From 6c05ec36709e22b76ed25b713f1a74f2c3b9279d Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Wed, 10 Jun 2026 10:26:29 -0700 Subject: [PATCH] Profiles: fix modal showing wrong active source + hero header / panel depth MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Correctness (the modal was lying): "Spotify (no auth)" is a COMPOSITE the Settings page stores as fallback_source='spotify' + metadata.spotify_free=true, not a literal 'spotify_free' value. The modal read the raw fallback_source and showed plain "Spotify" as active even when Settings clearly said "(no auth)". The endpoint now mirrors that mapping both ways — reports active='spotify_free' when the flag is set, and switching to it writes fallback_source=spotify + spotify_free=true (and clears the flag for any other source). Modal + Settings now always agree. Visual: the modal itself (not just the cards) is richer now — - a hero header per tab: big brand-logo disc + "Active source" eyebrow + the active name + a one-liner + an Active pill, all tinted by the brand color with a soft radial glow (the Manage-Workers hero feel); - the panel gained brand-tinted radial depth instead of flat black. Test: spotify_free composite round-trips like Settings (stored split + reported as spotify_free; flag clears on switch). 15 endpoint + 64 integrity tests pass. --- tests/test_credentials_endpoints.py | 15 +++++++++++++ web_server.py | 21 +++++++++++++++--- webui/static/service-switch.js | 33 ++++++++++++++++++++++++++--- webui/static/style.css | 33 ++++++++++++++++++++++++++++- 4 files changed, 95 insertions(+), 7 deletions(-) diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index df2d9c18..47d2817c 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -181,3 +181,18 @@ def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile): sess['profile_id'] = nonadmin_profile assert client.get('/api/profiles/me/active-sources').get_json()['editable'] is False assert client.post('/api/profiles/active-sources', json={'metadata_source': 'deezer'}).status_code == 403 + + +def test_spotify_free_composite_roundtrips_like_settings(client): + # "Spotify (no auth)" is stored as fallback_source=spotify + spotify_free=true + # (the same composite the Settings page uses) — the modal must report it as + # active='spotify_free', not raw 'spotify'. + from config.settings import config_manager + assert client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify_free'}).get_json()['success'] + assert config_manager.get('metadata.fallback_source') == 'spotify' + assert config_manager.get('metadata.spotify_free') is True + assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify_free' + # Switching to plain spotify clears the flag. + client.post('/api/profiles/active-sources', json={'metadata_source': 'spotify'}) + assert config_manager.get('metadata.spotify_free') is False + assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'spotify' diff --git a/web_server.py b/web_server.py index 8e4d3eea..4f217f6c 100644 --- a/web_server.py +++ b/web_server.py @@ -25641,6 +25641,14 @@ def get_active_sources(): try: mode = config_manager.get('download_source.mode', 'soulseek') or 'soulseek' hybrid_order = config_manager.get('download_source.hybrid_order', []) or [] + # "Spotify (no auth)" is a COMPOSITE the Settings page uses: it stores + # fallback_source='spotify' + metadata.spotify_free=true, NOT a literal + # 'spotify_free' fallback value. Mirror that mapping so the modal agrees + # with the Settings dropdown (settings.js _metaSel / save logic). + _fb = config_manager.get('metadata.fallback_source', 'deezer') or 'deezer' + _free = config_manager.get('metadata.spotify_free', False) + meta_active = 'spotify_free' if (_fb == 'spotify' and _free) else _fb + meta_effective = 'spotify_free' if meta_active == 'spotify_free' else _get_metadata_fallback_source() return jsonify({ 'success': True, 'editable': get_current_profile_id() == 1, # admin writes the global default @@ -25650,8 +25658,8 @@ def get_active_sources(): # fallback (e.g. configured 'spotify' but not authenticated → # the app falls back). Surfacing both stops the modal disagreeing # with the sidebar/Settings status. - 'active': config_manager.get('metadata.fallback_source', 'deezer') or 'deezer', - 'effective': _get_metadata_fallback_source(), + 'active': meta_active, + 'effective': meta_effective, 'options': [{'id': s, 'available': _qs_metadata_available(s)} for s in _QS_METADATA_SOURCES], }, 'server': { @@ -25683,7 +25691,14 @@ def set_active_sources(): src = data['metadata_source'] if src not in _QS_METADATA_SOURCES: return jsonify({'success': False, 'error': 'Unknown metadata source'}), 400 - config_manager.set('metadata.fallback_source', src) + # Same composite the Settings save uses: 'spotify_free' is stored as + # fallback_source='spotify' + metadata.spotify_free=true. + if src == 'spotify_free': + config_manager.set('metadata.fallback_source', 'spotify') + config_manager.set('metadata.spotify_free', True) + else: + config_manager.set('metadata.fallback_source', src) + config_manager.set('metadata.spotify_free', False) invalidate_metadata_status_caches() changed.append('metadata') diff --git a/webui/static/service-switch.js b/webui/static/service-switch.js index f046f4a2..15983ed1 100644 --- a/webui/static/service-switch.js +++ b/webui/static/service-switch.js @@ -173,12 +173,39 @@ function _ssCard({ logo, emoji, label, active, available, onclick, badge, brand `; } +const _SS_TAB_BLURB = { + metadata: 'Where artist, album & track details come from.', + server: 'The library backend SoulSync reads and writes.', + download: 'Where SoulSync grabs tracks you don\'t have yet.', +}; + +function _ssHero(kind) { + const cur = _ssRailCurrent(kind); + if (!cur) return ''; + const media = cur.logo + ? `` + : `${cur.emoji}`; + const eyebrow = kind === 'metadata' ? 'Active metadata source' + : kind === 'server' ? 'Active media server' : 'Active download source'; + return ` +
+
${media}
+
+
${eyebrow}
+
${_ssEsc(cur.label)}
+
${_SS_TAB_BLURB[kind] || ''}
+
+ Active +
`; +} + function _ssRenderPanel() { const panel = document.getElementById('ss-panel'); const d = _ssState.data; if (!panel) return; if (!d || !d.success) { panel.innerHTML = '
Could not load active sources.
'; return; } const editable = !!d.editable; + panel.style.setProperty('--ss-brand', (_ssRailCurrent(_ssState.tab) || {}).brand || '#7c5cff'); const sub = document.getElementById('ss-topbar-sub'); if (sub) sub.textContent = editable ? 'What this profile uses for metadata, library, and downloads' @@ -199,7 +226,7 @@ function _ssRenderPanel() { const note = (eff && eff !== d.metadata.active) ? `
Configured source isn't connected — actually using ${_ssEsc((_ssMetaInfo(eff).text) || eff)} right now.
` : ''; - panel.innerHTML = `
Metadata source
${note}
${cards}
`; + panel.innerHTML = `${_ssHero('metadata')}
Choose source
${note}
${cards}
`; } else if (_ssState.tab === 'server') { const cards = d.server.options.map(o => { const info = _SS_SERVER_INFO[o.id] || { name: o.id }; @@ -209,7 +236,7 @@ function _ssRenderPanel() { onclick: (editable && o.available) ? `setActiveSource('server','${o.id}')` : null, }); }).join(''); - panel.innerHTML = `
Media server
${cards}
`; + panel.innerHTML = `${_ssHero('server')}
Choose server
${cards}
`; } else { _ssRenderDownloadPanel(panel, d, editable); } @@ -249,7 +276,7 @@ function _ssRenderDownloadPanel(panel, d, editable) { }).join(''); body = `
${cards}
`; } - panel.innerHTML = `
Download source
${toggle}${body}`; + panel.innerHTML = `${_ssHero('download')}
Choose source
${toggle}${body}`; if (isHybrid && editable) _ssWireHybridDrag(); } diff --git a/webui/static/style.css b/webui/static/style.css index af9e44ff..42b3e5bb 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -67668,7 +67668,38 @@ body.em-scroll-lock { overflow: hidden; } .ss-tab.active .ss-tab-cat { color: color-mix(in srgb, var(--ss-brand) 75%, white); } .ss-tab-cur { font-size: 0.92rem; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.ss-panel { flex: 1; padding: 22px 24px; overflow-y: auto; } +.ss-panel { + flex: 1; padding: 22px 24px; overflow-y: auto; + background: + radial-gradient(120% 80% at 100% 0%, color-mix(in srgb, var(--ss-brand) 9%, transparent), transparent 60%), + radial-gradient(90% 60% at 0% 100%, rgba(255,255,255,0.02), transparent 70%); +} +.ss-hero { + position: relative; display: flex; align-items: center; gap: 16px; padding: 16px 18px; margin-bottom: 20px; + border-radius: 16px; overflow: hidden; + background: linear-gradient(120deg, color-mix(in srgb, var(--ss-brand) 24%, transparent), rgba(255,255,255,0.025)); + border: 1px solid color-mix(in srgb, var(--ss-brand) 40%, transparent); +} +.ss-hero::after { + content: ''; position: absolute; right: -40px; top: -60px; width: 180px; height: 180px; border-radius: 50%; + background: radial-gradient(circle, color-mix(in srgb, var(--ss-brand) 40%, transparent), transparent 70%); + pointer-events: none; +} +.ss-hero-disc { + width: 60px; height: 60px; flex-shrink: 0; border-radius: 50%; background: #fff; + display: flex; align-items: center; justify-content: center; + box-shadow: 0 0 0 3px var(--ss-brand), 0 0 26px color-mix(in srgb, var(--ss-brand) 55%, transparent); +} +.ss-hero-logo { width: 36px; height: 36px; object-fit: contain; } +.ss-hero-emoji { font-size: 1.8rem; } +.ss-hero-info { flex: 1; min-width: 0; } +.ss-hero-eyebrow { font-size: 0.68rem; text-transform: uppercase; letter-spacing: 0.1em; color: color-mix(in srgb, var(--ss-brand) 70%, white); font-weight: 700; } +.ss-hero-name { font-size: 1.35rem; font-weight: 700; margin: 1px 0 3px; } +.ss-hero-sub { font-size: 0.82rem; color: rgba(255,255,255,0.55); } +.ss-hero-pill { + align-self: flex-start; font-size: 0.66rem; font-weight: 700; text-transform: uppercase; letter-spacing: 0.06em; + padding: 4px 10px; border-radius: 999px; color: #0a0a0a; background: var(--ss-brand); +} .ss-section-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.45); margin-bottom: 16px; } .ss-hint { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-bottom: 12px; } .ss-empty { color: rgba(255, 255, 255, 0.4); font-style: italic; padding: 30px 0; text-align: center; }