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:
parent
156c890de7
commit
22202104ef
7 changed files with 535 additions and 86 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
120
web_server.py
120
web_server.py
|
|
@ -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'])
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 */ }
|
||||
}
|
||||
|
|
|
|||
276
webui/static/service-switch.js
Normal file
276
webui/static/service-switch.js
Normal 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 =>
|
||||
({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[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()">×</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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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; }
|
||||
|
|
|
|||
Loading…
Reference in a new issue