Profiles: redesign the Service Status modal — active source/server/download switcher

Replaces the basic credential-pill quick-switch with a Manage-Workers-styled
modal (topbar + left rail + panel, entrance animation, brand-logo cards).

- Sidebar Service Status: whole panel opens the modal; clicking the Metadata /
  Media Server / Download rows deep-links straight to that tab. Removed the
  "switch ▸" hover text.
- Three tabs: Metadata (source logo cards, unavailable ones dimmed), Server
  (Plex/Jellyfin/Navidrome/SoulSync logos), Download (Single⇄Hybrid segmented
  toggle; Hybrid shows a draggable priority list). Logos reuse SOURCE_LABELS +
  HYBRID_SOURCES; active card gets an accent ring + check.
- Admin writes the GLOBAL active source/server/download (reuses the same setters
  + client reloads as the Settings save, so changes take effect immediately).
  Non-admins see it read-only (editable=false) — the per-profile override is the
  next layer.

Backend: GET /api/profiles/me/active-sources (any profile; reports editable),
POST /api/profiles/active-sources (@admin_only; validates against the allowed
metadata/server/download lists, applies + reloads). New service-switch.js
(registered + in the integrity registry); old modal removed from
credential-sets.js (admin Connected Accounts manager stays).

Tests: 14 endpoint tests — read shape, admin sets metadata/hybrid+order
(reflected), bad-value 400s, non-admin read-only + 403 on write. 64 integrity
tests pass; real-app smoke confirms render + deep-links + the full set/reflect
cycle.
This commit is contained in:
BoulderBadgeDad 2026-06-10 09:45:32 -07:00
parent 156c890de7
commit 22202104ef
7 changed files with 535 additions and 86 deletions

View file

@ -149,3 +149,35 @@ def test_select_rejects_wrong_service_or_missing_set(client):
# Unsupported service rejected.
assert client.post('/api/profiles/me/services/select',
json={'service': 'itunes', 'credential_id': None}).status_code == 400
# ── Quick-switch: active source/server/download (admin=global, non-admin read-only) ──
def test_active_sources_read_shape(client):
a = client.get('/api/profiles/me/active-sources').get_json()
assert a['success'] and a['editable'] is True # default session = admin
assert a['metadata']['active'] and len(a['metadata']['options']) == 6
assert len(a['server']['options']) == 4
assert 'mode' in a['download'] and isinstance(a['download']['hybrid_order'], list)
def test_admin_sets_global_active_sources(client):
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'itunes'}).get_json()['success']
assert client.get('/api/profiles/me/active-sources').get_json()['metadata']['active'] == 'itunes'
# hybrid + order round-trips
client.post('/api/profiles/active-sources', json={'download_mode': 'hybrid', 'hybrid_order': ['hifi', 'soulseek']})
dl = client.get('/api/profiles/me/active-sources').get_json()['download']
assert dl['mode'] == 'hybrid' and dl['hybrid_order'] == ['hifi', 'soulseek']
def test_active_sources_rejects_bad_values(client):
assert client.post('/api/profiles/active-sources', json={'metadata_source': 'nope'}).status_code == 400
assert client.post('/api/profiles/active-sources', json={'media_server': 'nope'}).status_code == 400
assert client.post('/api/profiles/active-sources', json={'download_mode': 'nope'}).status_code == 400
def test_active_sources_nonadmin_readonly_and_blocked(client, nonadmin_profile):
with client.session_transaction() as sess:
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

View file

@ -53,7 +53,7 @@ SPLIT_MODULES = [
# Other JS files that exist in static/ but are NOT part of the split
NON_SPLIT_JS = {"setup-wizard.js", "docs.js", "helper.js", "particles.js", "worker-orbs.js",
"enrichment-manager.js", "origin-history.js", "blocklist.js",
"watchlist-history.js", "credential-sets.js"}
"watchlist-history.js", "credential-sets.js", "service-switch.js"}
# Pre-existing duplicate helper functions that lived in the original monolith.
# In a plain <script> context the last-loaded declaration wins. These are NOT

View file

@ -25595,6 +25595,126 @@ def select_my_service_credential():
return jsonify({'success': False, 'error': str(e)}), 500
# ==================================================================================
# QUICK-SWITCH: active metadata source / media server / download source
# ----------------------------------------------------------------------------------
# The read is open to any profile (so the sidebar modal renders). Setting is
# admin-only and writes the GLOBAL config (same as the Settings page) — the
# per-profile override for non-admins is a separate, later layer. `editable`
# tells the UI whether to allow changes for the current profile.
# ==================================================================================
# Selectable metadata sources (mirrors the Settings <select>).
_QS_METADATA_SOURCES = ['spotify', 'spotify_free', 'itunes', 'deezer', 'discogs', 'musicbrainz']
_QS_MEDIA_SERVERS = ['plex', 'jellyfin', 'navidrome', 'soulsync']
# Single download sources (everything the mode accepts except 'hybrid').
_QS_DOWNLOAD_SOURCES = ['soulseek', 'youtube', 'tidal', 'qobuz', 'hifi', 'torrent', 'usenet']
def _qs_metadata_available(source):
try:
if source == 'spotify':
return bool(spotify_client and spotify_client.is_spotify_authenticated())
if source == 'discogs':
return bool(config_manager.get('discogs.token'))
return True
except Exception:
return True
def _qs_server_available(server):
try:
if server == 'soulsync':
return True
if server == 'plex':
return bool(config_manager.get('plex.base_url') or config_manager.get('plex.token'))
return bool(config_manager.get(f'{server}.base_url'))
except Exception:
return True
@app.route('/api/profiles/me/active-sources', methods=['GET'])
def get_active_sources():
"""Current active metadata source / media server / download source + the
available options, for the quick-switch modal. Readable by any profile;
reflects the global config (per-profile override is a later layer)."""
try:
mode = config_manager.get('download_source.mode', 'soulseek') or 'soulseek'
hybrid_order = config_manager.get('download_source.hybrid_order', []) or []
return jsonify({
'success': True,
'editable': get_current_profile_id() == 1, # admin writes the global default
'metadata': {
'active': config_manager.get('metadata.fallback_source', 'deezer') or 'deezer',
'options': [{'id': s, 'available': _qs_metadata_available(s)} for s in _QS_METADATA_SOURCES],
},
'server': {
'active': config_manager.get_active_media_server(),
'options': [{'id': s, 'available': _qs_server_available(s)} for s in _QS_MEDIA_SERVERS],
},
'download': {
'mode': mode,
'hybrid_order': hybrid_order,
'options': [{'id': s} for s in _QS_DOWNLOAD_SOURCES],
},
})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
@app.route('/api/profiles/active-sources', methods=['POST'])
@admin_only
def set_active_sources():
"""Set the GLOBAL active metadata source / media server / download mode +
hybrid order (whichever fields are present). Admin-only; reuses the same
setters + client reloads the Settings save performs so changes take effect
immediately."""
try:
data = request.json or {}
changed = []
if 'metadata_source' in data:
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)
invalidate_metadata_status_caches()
changed.append('metadata')
if 'media_server' in data:
srv = data['media_server']
if srv not in _QS_MEDIA_SERVERS:
return jsonify({'success': False, 'error': 'Unknown media server'}), 400
config_manager.set_active_media_server(srv)
for s in ('plex', 'jellyfin', 'navidrome'):
c = media_server_engine.client(s)
if c:
if s == 'plex':
c.server = None
else:
c.reload_config()
changed.append('server')
if 'download_mode' in data:
mode = data['download_mode']
if mode not in (_QS_DOWNLOAD_SOURCES + ['hybrid']):
return jsonify({'success': False, 'error': 'Unknown download mode'}), 400
config_manager.set('download_source.mode', mode)
changed.append('download')
if 'hybrid_order' in data and isinstance(data['hybrid_order'], list):
clean = [s for s in data['hybrid_order'] if s in _QS_DOWNLOAD_SOURCES]
config_manager.set('download_source.hybrid_order', clean)
changed.append('download')
if 'download' in changed and download_orchestrator:
download_orchestrator.reload_settings()
return jsonify({'success': True, 'changed': sorted(set(changed))})
except Exception as e:
return jsonify({'success': False, 'error': str(e)}), 500
# --- Watchlist API Endpoints ---
@app.route('/api/watchlist/count', methods=['GET'])

View file

@ -283,17 +283,17 @@
</div>
<!-- Status Section -->
<div class="status-section status-section--clickable" onclick="openServiceSwitchModal()" title="Switch which account each service uses" role="button" tabindex="0">
<h4 class="status-title">Service Status <span class="status-switch-hint">switch ▸</span></h4>
<div class="status-indicator" id="metadata-source-indicator" data-status-ready="false">
<div class="status-section status-section--clickable" onclick="openServiceSwitchModal('metadata')" title="Change active sources for your profile" role="button" tabindex="0">
<h4 class="status-title">Service Status</h4>
<div class="status-indicator" id="metadata-source-indicator" data-status-ready="false" onclick="event.stopPropagation(); openServiceSwitchModal('metadata')">
<span class="status-dot disconnected"></span>
<span class="status-name" id="metadata-source-name">Metadata Source</span>
</div>
<div class="status-indicator" id="media-server-indicator" data-status-ready="false">
<div class="status-indicator" id="media-server-indicator" data-status-ready="false" onclick="event.stopPropagation(); openServiceSwitchModal('server')">
<span class="status-dot disconnected"></span>
<span class="status-name" id="media-server-name">Media Server</span>
</div>
<div class="status-indicator" id="soulseek-indicator" data-status-ready="false">
<div class="status-indicator" id="soulseek-indicator" data-status-ready="false" onclick="event.stopPropagation(); openServiceSwitchModal('download')">
<span class="status-dot disconnected"></span>
<span class="status-name" id="download-source-name">Download Source</span>
</div>
@ -8198,6 +8198,7 @@
<script src="{{ url_for('static', filename='watchlist-history.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='blocklist.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='credential-sets.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='service-switch.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-services.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>

View file

@ -148,83 +148,3 @@ async function deleteCredentialSet(id, serviceName) {
}
} catch (e) { /* no-op */ }
}
// ── Quick-switch modal (sidebar Service Status → switch active account) ──────
function openServiceSwitchModal() {
let overlay = document.getElementById('service-switch-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'service-switch-overlay';
overlay.className = 'modal-overlay cred-switch-overlay';
overlay.innerHTML = `
<div class="cred-switch-modal">
<div class="cred-switch-head">
<div>
<h2 class="cred-switch-title">Quick Switch</h2>
<p class="cred-switch-sub">Choose which account each service uses for your profile. Set up by the admin.</p>
</div>
<button class="cred-switch-close" onclick="closeServiceSwitchModal()" aria-label="Close"></button>
</div>
<div class="cred-switch-body" id="cred-switch-body"></div>
</div>`;
overlay.addEventListener('click', (e) => { if (e.target === overlay) closeServiceSwitchModal(); });
document.body.appendChild(overlay);
}
overlay.classList.remove('hidden');
_loadServiceSwitch();
}
function closeServiceSwitchModal() {
const o = document.getElementById('service-switch-overlay');
if (o) o.classList.add('hidden');
}
async function _loadServiceSwitch() {
const body = document.getElementById('cred-switch-body');
if (body) body.innerHTML = '<div class="cred-empty">Loading…</div>';
try {
const res = await fetch('/api/profiles/me/services');
const data = await res.json();
_renderServiceSwitch(body, (data && data.services) || {});
} catch (e) {
if (body) body.innerHTML = '<div class="cred-empty">Could not load.</div>';
}
}
function _renderServiceSwitch(body, services) {
// Only show services the admin has actually set up alternate accounts for.
const rows = CRED_SERVICES.filter(svc => (services[svc.id]?.options || []).length).map(svc => {
const info = services[svc.id];
const selected = info.selected_id;
const pills = [
`<button class="cred-switch-pill${selected == null ? ' active' : ''}"
onclick="selectServiceCredential('${svc.id}', null)">Default</button>`,
...info.options.map(o => `
<button class="cred-switch-pill${selected === o.id ? ' active' : ''}"
onclick="selectServiceCredential('${svc.id}', ${o.id})">${_credEsc(o.label)}</button>`),
].join('');
return `
<div class="cred-switch-row">
<div class="cred-switch-svc">${svc.name}</div>
<div class="cred-switch-pills">${pills}</div>
</div>`;
}).join('');
body.innerHTML = rows ||
'<div class="cred-empty">No alternate accounts have been set up by the admin yet.</div>';
}
async function selectServiceCredential(service, credentialId) {
try {
const res = await fetch('/api/profiles/me/services/select', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ service, credential_id: credentialId }),
});
const data = await res.json();
if (data.success) {
_loadServiceSwitch(); // re-render with the new active pill
} else if (typeof showToast === 'function') {
showToast(data.error || 'Switch failed', 'error');
}
} catch (e) { /* no-op */ }
}

View file

@ -0,0 +1,276 @@
/*
* Quick-switch modal active Metadata / Server / Download source selection.
* Opens from the sidebar Service Status panel; styled after the Manage Workers
* hub (topbar + rail + panel, brand-logo cards).
*
* Admin writes the GLOBAL active source/server/download (same as Settings).
* Non-admins see it read-only for now (per-profile override is a later layer):
* the backend reports `editable`, and the UI disables changes when false.
*
* Backend: GET /api/profiles/me/active-sources, POST /api/profiles/active-sources.
*/
const _SS_TABS = [
{ id: 'metadata', name: 'Metadata', emoji: '🎼' },
{ id: 'server', name: 'Server', emoji: '🖥️' },
{ id: 'download', name: 'Download', emoji: '⬇️' },
];
// Brand logos. Metadata pulls from SOURCE_LABELS (shared-helpers.js) when
// available; server + download have their own small maps.
const _SS_SERVER_INFO = {
plex: { name: 'Plex', logo: 'https://www.plex.tv/wp-content/themes/plex/assets/img/plex-logo.svg' },
jellyfin: { name: 'Jellyfin', logo: 'https://jellyfin.org/images/logo.svg' },
navidrome: { name: 'Navidrome', logo: 'https://www.navidrome.org/images/navidrome-logo-200x150.png' },
soulsync: { name: 'SoulSync', logo: '/static/trans2.png' },
};
const _SS_META_FALLBACK = {
spotify_free: { text: 'Spotify (no auth)', icon: '🆓', logo: 'https://storage.googleapis.com/pr-newsroom-wp/1/2023/05/Spotify_Primary_Logo_RGB_Green.png' },
};
let _ssState = { tab: 'metadata', data: null };
function _ssEsc(s) {
return String(s == null ? '' : s).replace(/[&<>"']/g, c =>
({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
function _ssMetaInfo(id) {
if (typeof SOURCE_LABELS !== 'undefined' && SOURCE_LABELS[id]) return SOURCE_LABELS[id];
if (_SS_META_FALLBACK[id]) return _SS_META_FALLBACK[id];
return { text: id, icon: '🎵' };
}
function _ssDownloadInfo(id) {
if (typeof HYBRID_SOURCES !== 'undefined') {
const h = HYBRID_SOURCES.find(s => s.id === id);
if (h) return { name: h.name, logo: h.icon, emoji: h.emoji };
}
return { name: id, emoji: '⬇️' };
}
function openServiceSwitchModal(tab) {
_ssState.tab = _SS_TABS.some(t => t.id === tab) ? tab : 'metadata';
let overlay = document.getElementById('service-switch-overlay');
if (!overlay) {
overlay = document.createElement('div');
overlay.id = 'service-switch-overlay';
overlay.className = 'modal-overlay ss-overlay hidden';
overlay.onclick = (e) => { if (e.target === overlay) closeServiceSwitchModal(); };
overlay.innerHTML = `
<div class="ss-modal" role="dialog" aria-modal="true" aria-label="Active Sources" tabindex="-1">
<div class="ss-topbar">
<div class="ss-topbar-icon"><img src="/static/trans2.png" alt="SoulSync" class="ss-topbar-logo"></div>
<div class="ss-topbar-titles">
<h3 class="ss-topbar-title">Active Sources</h3>
<div class="ss-topbar-sub" id="ss-topbar-sub">What this profile uses for metadata, library, and downloads</div>
</div>
<button class="ss-icon-btn ss-icon-btn--close" title="Close" onclick="closeServiceSwitchModal()">&times;</button>
</div>
<div class="ss-body">
<div class="ss-rail" id="ss-rail"></div>
<div class="ss-panel" id="ss-panel"></div>
</div>
</div>`;
document.body.appendChild(overlay);
}
overlay.classList.remove('hidden', 'ss-closing');
const modal = overlay.querySelector('.ss-modal');
if (modal) { modal.classList.remove('ss-in'); void modal.offsetWidth; modal.classList.add('ss-in'); }
document.addEventListener('keydown', _ssOnKeydown);
_ssLoad();
}
function closeServiceSwitchModal() {
const o = document.getElementById('service-switch-overlay');
if (o) o.classList.add('hidden');
document.removeEventListener('keydown', _ssOnKeydown);
}
function _ssOnKeydown(e) { if (e.key === 'Escape') closeServiceSwitchModal(); }
async function _ssLoad() {
_ssRenderRail();
const panel = document.getElementById('ss-panel');
if (panel) panel.innerHTML = '<div class="ss-empty">Loading…</div>';
try {
const res = await fetch('/api/profiles/me/active-sources');
_ssState.data = await res.json();
} catch (e) {
_ssState.data = null;
}
_ssRenderPanel();
}
function _ssRenderRail() {
const rail = document.getElementById('ss-rail');
if (!rail) return;
rail.innerHTML = _SS_TABS.map(t => `
<button class="ss-tab${t.id === _ssState.tab ? ' active' : ''}" onclick="switchServiceSwitchTab('${t.id}')">
<span class="ss-tab-emoji">${t.emoji}</span>
<span class="ss-tab-label">${t.name}</span>
</button>`).join('');
}
function switchServiceSwitchTab(tab) {
_ssState.tab = tab;
_ssRenderRail();
_ssRenderPanel();
}
function _ssCard({ logo, emoji, label, active, available, onclick, badge }) {
const dim = available === false ? ' ss-card--locked' : '';
const act = active ? ' active' : '';
const media = logo
? `<img class="ss-card-logo" src="${logo}" alt="" onerror="this.outerHTML='<span class=\\'ss-card-emoji\\'>${emoji || '🎵'}</span>'">`
: `<span class="ss-card-emoji">${emoji || '🎵'}</span>`;
return `
<button class="ss-card${act}${dim}" ${onclick ? `onclick="${onclick}"` : 'disabled'}>
${media}
<span class="ss-card-label">${_ssEsc(label)}</span>
${badge ? `<span class="ss-card-badge">${_ssEsc(badge)}</span>` : ''}
${active ? '<span class="ss-card-check">✓</span>' : ''}
</button>`;
}
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;
const sub = document.getElementById('ss-topbar-sub');
if (sub) sub.textContent = editable
? 'What this profile uses for metadata, library, and downloads'
: 'Set by the admin — view only for now';
if (_ssState.tab === 'metadata') {
const cards = d.metadata.options.map(o => {
const info = _ssMetaInfo(o.id);
return _ssCard({
logo: info.logo, emoji: info.icon, label: info.text || o.id,
active: d.metadata.active === o.id, available: o.available,
onclick: (editable && o.available) ? `setActiveSource('metadata','${o.id}')` : null,
});
}).join('');
panel.innerHTML = `<div class="ss-section-title">Metadata source</div><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 };
return _ssCard({
logo: info.logo, emoji: '🖥️', label: info.name,
active: d.server.active === o.id, available: o.available,
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>`;
} else {
_ssRenderDownloadPanel(panel, d, editable);
}
}
function _ssRenderDownloadPanel(panel, d, editable) {
const isHybrid = d.download.mode === 'hybrid';
const toggle = `
<div class="ss-seg">
<button class="ss-seg-btn${!isHybrid ? ' active' : ''}" ${editable ? `onclick="setDownloadMode('single')"` : 'disabled'}>Single source</button>
<button class="ss-seg-btn${isHybrid ? ' active' : ''}" ${editable ? `onclick="setDownloadMode('hybrid')"` : 'disabled'}>Hybrid</button>
</div>`;
let body;
if (isHybrid) {
const order = (d.download.hybrid_order && d.download.hybrid_order.length)
? d.download.hybrid_order
: d.download.options.map(o => o.id);
body = `<div class="ss-hint">Drag to set priority — SoulSync tries each in order.</div>
<div class="ss-hybrid-list" id="ss-hybrid-list">` +
order.map((id, i) => {
const info = _ssDownloadInfo(id);
return `<div class="ss-hybrid-item" draggable="${editable}" data-src="${id}">
<span class="ss-hybrid-rank">${i + 1}</span>
${info.logo ? `<img class="ss-hybrid-logo" src="${info.logo}" onerror="this.outerHTML='<span class=\\'ss-card-emoji\\'>${info.emoji}</span>'">` : `<span class="ss-card-emoji">${info.emoji}</span>`}
<span class="ss-hybrid-name">${_ssEsc(info.name)}</span>
</div>`;
}).join('') + `</div>`;
} else {
const cards = d.download.options.map(o => {
const info = _ssDownloadInfo(o.id);
return _ssCard({
logo: info.logo, emoji: info.emoji, label: info.name,
active: d.download.mode === o.id, available: true,
onclick: editable ? `setActiveSource('download','${o.id}')` : null,
});
}).join('');
body = `<div class="ss-grid">${cards}</div>`;
}
panel.innerHTML = `<div class="ss-section-title">Download source</div>${toggle}${body}`;
if (isHybrid && editable) _ssWireHybridDrag();
}
function _ssWireHybridDrag() {
const list = document.getElementById('ss-hybrid-list');
if (!list) return;
list.querySelectorAll('.ss-hybrid-item').forEach(item => {
item.addEventListener('dragstart', (e) => {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', item.dataset.src);
item.classList.add('dragging');
});
item.addEventListener('dragend', () => item.classList.remove('dragging'));
item.addEventListener('dragover', (e) => { e.preventDefault(); });
item.addEventListener('drop', (e) => {
e.preventDefault();
const dragged = e.dataTransfer.getData('text/plain');
if (dragged && dragged !== item.dataset.src) _ssReorderHybrid(dragged, item.dataset.src);
});
});
}
function _ssReorderHybrid(draggedId, targetId) {
const order = (_ssState.data.download.hybrid_order && _ssState.data.download.hybrid_order.length)
? _ssState.data.download.hybrid_order.slice()
: _ssState.data.download.options.map(o => o.id);
const from = order.indexOf(draggedId);
if (from < 0) return;
order.splice(from, 1);
const to = order.indexOf(targetId);
order.splice(to < 0 ? order.length : to, 0, draggedId);
_ssSave({ hybrid_order: order });
}
async function setActiveSource(kind, id) {
const key = kind === 'metadata' ? 'metadata_source' : kind === 'server' ? 'media_server' : 'download_mode';
await _ssSave({ [key]: id });
}
async function setDownloadMode(which) {
if (which === 'hybrid') {
await _ssSave({ download_mode: 'hybrid' });
} else {
// Switch to a single source — keep the current single choice if it was
// already single, else default to the first option.
const d = _ssState.data;
const cur = d.download.mode;
const single = (cur && cur !== 'hybrid') ? cur : (d.download.options[0] && d.download.options[0].id) || 'soulseek';
await _ssSave({ download_mode: single });
}
}
async function _ssSave(patch) {
try {
const res = await fetch('/api/profiles/active-sources', {
method: 'POST', headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(patch),
});
const data = await res.json();
if (!data.success) {
if (typeof showToast === 'function') showToast(data.error || 'Change failed', 'error');
return;
}
if (typeof showToast === 'function') showToast('Updated', 'success');
await _ssLoad(); // re-read + re-render with the new active state
if (typeof fetchAndUpdateServiceStatus === 'function') fetchAndUpdateServiceStatus();
} catch (e) {
if (typeof showToast === 'function') showToast('Change failed', 'error');
}
}

View file

@ -67612,3 +67612,103 @@ body.em-scroll-lock { overflow: hidden; }
background: rgb(var(--accent-light-rgb)); color: #0a0a0a;
border-color: rgb(var(--accent-light-rgb)); font-weight: 600;
}
/* ── Quick-switch modal (active Metadata / Server / Download) — Manage-Workers style ── */
.ss-overlay { display: flex; align-items: center; justify-content: center; }
.ss-modal {
background: linear-gradient(160deg, #181a21 0%, #121319 100%);
border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 18px; width: min(760px, 94vw); max-height: 86vh;
display: flex; flex-direction: column; overflow: hidden;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.65);
}
.ss-modal.ss-in { animation: ss-pop 0.22s cubic-bezier(0.2, 0.9, 0.3, 1.2); }
@keyframes ss-pop { from { opacity: 0; transform: translateY(14px) scale(0.97); } to { opacity: 1; transform: none; } }
.ss-topbar {
display: flex; align-items: center; gap: 14px; padding: 18px 20px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
background: rgba(var(--accent-light-rgb), 0.05);
}
.ss-topbar-icon { width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; }
.ss-topbar-logo { width: 36px; height: 36px; object-fit: contain; }
.ss-topbar-titles { flex: 1; }
.ss-topbar-title { margin: 0; font-size: 1.2rem; }
.ss-topbar-sub { color: rgba(255, 255, 255, 0.5); font-size: 0.82rem; margin-top: 2px; }
.ss-icon-btn {
background: rgba(255, 255, 255, 0.06); border: none; color: #fff;
width: 34px; height: 34px; border-radius: 9px; cursor: pointer; font-size: 1.1rem;
}
.ss-icon-btn:hover { background: rgba(255, 255, 255, 0.14); }
.ss-body { display: flex; min-height: 320px; max-height: calc(86vh - 78px); }
.ss-rail {
width: 168px; flex-shrink: 0; padding: 14px 10px; display: flex; flex-direction: column; gap: 6px;
border-right: 1px solid rgba(255, 255, 255, 0.06); background: rgba(0, 0, 0, 0.18);
}
.ss-tab {
display: flex; align-items: center; gap: 10px; padding: 11px 14px;
background: transparent; border: none; border-radius: 10px; cursor: pointer;
color: rgba(255, 255, 255, 0.65); font-size: 0.92rem; text-align: left; transition: all 0.15s;
}
.ss-tab:hover { background: rgba(255, 255, 255, 0.05); color: #fff; }
.ss-tab.active {
background: rgba(var(--accent-light-rgb), 0.16);
color: rgb(var(--accent-light-rgb)); font-weight: 600;
box-shadow: inset 2px 0 0 rgb(var(--accent-light-rgb));
}
.ss-tab-emoji { font-size: 1.1rem; }
.ss-panel { flex: 1; padding: 20px 22px; overflow-y: auto; }
.ss-section-title { font-size: 0.78rem; text-transform: uppercase; letter-spacing: 0.08em; color: rgba(255, 255, 255, 0.45); margin-bottom: 14px; }
.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; }
.ss-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(120px, 1fr)); gap: 12px; }
.ss-card {
position: relative; display: flex; flex-direction: column; align-items: center; gap: 10px;
padding: 18px 12px; background: rgba(255, 255, 255, 0.035);
border: 1px solid rgba(255, 255, 255, 0.08); border-radius: 14px;
cursor: pointer; transition: all 0.16s; color: #fff;
}
.ss-card:hover:not([disabled]) { background: rgba(255, 255, 255, 0.07); transform: translateY(-2px); border-color: rgba(var(--accent-light-rgb), 0.4); }
.ss-card.active {
border-color: rgb(var(--accent-light-rgb));
background: rgba(var(--accent-light-rgb), 0.12);
box-shadow: 0 0 0 1px rgb(var(--accent-light-rgb)), 0 8px 24px rgba(var(--accent-light-rgb), 0.18);
}
.ss-card--locked { opacity: 0.4; cursor: not-allowed; }
.ss-card[disabled] { cursor: default; }
.ss-card-logo { width: 42px; height: 42px; object-fit: contain; }
.ss-card-emoji { font-size: 2rem; line-height: 1; }
.ss-card-label { font-size: 0.85rem; text-align: center; }
.ss-card-badge {
position: absolute; top: 6px; left: 6px; font-size: 0.62rem; padding: 2px 6px;
background: rgba(0, 0, 0, 0.5); border-radius: 6px; color: rgba(255, 255, 255, 0.7);
}
.ss-card-check {
position: absolute; top: 6px; right: 8px; color: rgb(var(--accent-light-rgb)); font-weight: 700;
}
.ss-seg { display: inline-flex; background: rgba(0, 0, 0, 0.25); border-radius: 10px; padding: 4px; margin-bottom: 16px; }
.ss-seg-btn {
border: none; background: transparent; color: rgba(255, 255, 255, 0.6);
padding: 7px 18px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; transition: all 0.15s;
}
.ss-seg-btn.active { background: rgb(var(--accent-light-rgb)); color: #0a0a0a; font-weight: 600; }
.ss-seg-btn[disabled] { cursor: default; }
.ss-hybrid-list { display: flex; flex-direction: column; gap: 8px; }
.ss-hybrid-item {
display: flex; align-items: center; gap: 12px; padding: 10px 14px;
background: rgba(255, 255, 255, 0.04); border: 1px solid rgba(255, 255, 255, 0.08);
border-radius: 10px; cursor: grab;
}
.ss-hybrid-item.dragging { opacity: 0.5; cursor: grabbing; border-color: rgb(var(--accent-light-rgb)); }
.ss-hybrid-rank {
width: 22px; height: 22px; flex-shrink: 0; display: flex; align-items: center; justify-content: center;
background: rgba(var(--accent-light-rgb), 0.2); color: rgb(var(--accent-light-rgb));
border-radius: 50%; font-size: 0.72rem; font-weight: 700;
}
.ss-hybrid-logo { width: 26px; height: 26px; object-fit: contain; }
.ss-hybrid-name { font-size: 0.9rem; }