Profiles: fix modal showing wrong active source + hero header / panel depth
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 <kind> 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.
This commit is contained in:
parent
cd59d75531
commit
6c05ec3670
4 changed files with 95 additions and 7 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
||||
|
|
|
|||
|
|
@ -173,12 +173,39 @@ function _ssCard({ logo, emoji, label, active, available, onclick, badge, brand
|
|||
</button>`;
|
||||
}
|
||||
|
||||
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
|
||||
? `<img class="ss-hero-logo" src="${cur.logo}" onerror="this.outerHTML='<span class=\\'ss-hero-emoji\\'>${cur.emoji}</span>'">`
|
||||
: `<span class="ss-hero-emoji">${cur.emoji}</span>`;
|
||||
const eyebrow = kind === 'metadata' ? 'Active metadata source'
|
||||
: kind === 'server' ? 'Active media server' : 'Active download source';
|
||||
return `
|
||||
<div class="ss-hero" style="--ss-brand:${cur.brand}">
|
||||
<div class="ss-hero-disc">${media}</div>
|
||||
<div class="ss-hero-info">
|
||||
<div class="ss-hero-eyebrow">${eyebrow}</div>
|
||||
<div class="ss-hero-name">${_ssEsc(cur.label)}</div>
|
||||
<div class="ss-hero-sub">${_SS_TAB_BLURB[kind] || ''}</div>
|
||||
</div>
|
||||
<span class="ss-hero-pill">Active</span>
|
||||
</div>`;
|
||||
}
|
||||
|
||||
function _ssRenderPanel() {
|
||||
const panel = document.getElementById('ss-panel');
|
||||
const d = _ssState.data;
|
||||
if (!panel) return;
|
||||
if (!d || !d.success) { panel.innerHTML = '<div class="ss-empty">Could not load active sources.</div>'; 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)
|
||||
? `<div class="ss-effective-note">Configured source isn't connected — actually using <b>${_ssEsc((_ssMetaInfo(eff).text) || eff)}</b> right now.</div>`
|
||||
: '';
|
||||
panel.innerHTML = `<div class="ss-section-title">Metadata source</div>${note}<div class="ss-grid">${cards}</div>`;
|
||||
panel.innerHTML = `${_ssHero('metadata')}<div class="ss-section-title">Choose source</div>${note}<div class="ss-grid">${cards}</div>`;
|
||||
} 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 = `<div class="ss-section-title">Media server</div><div class="ss-grid">${cards}</div>`;
|
||||
panel.innerHTML = `${_ssHero('server')}<div class="ss-section-title">Choose server</div><div class="ss-grid">${cards}</div>`;
|
||||
} else {
|
||||
_ssRenderDownloadPanel(panel, d, editable);
|
||||
}
|
||||
|
|
@ -249,7 +276,7 @@ function _ssRenderDownloadPanel(panel, d, editable) {
|
|||
}).join('');
|
||||
body = `<div class="ss-grid">${cards}</div>`;
|
||||
}
|
||||
panel.innerHTML = `<div class="ss-section-title">Download source</div>${toggle}${body}`;
|
||||
panel.innerHTML = `${_ssHero('download')}<div class="ss-section-title">Choose source</div>${toggle}${body}`;
|
||||
if (isHybrid && editable) _ssWireHybridDrag();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue