diff --git a/tests/test_credentials_endpoints.py b/tests/test_credentials_endpoints.py index e4399115..df2d9c18 100644 --- a/tests/test_credentials_endpoints.py +++ b/tests/test_credentials_endpoints.py @@ -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 diff --git a/tests/test_script_split_integrity.py b/tests/test_script_split_integrity.py index 7224abdd..ba08b4df 100644 --- a/tests/test_script_split_integrity.py +++ b/tests/test_script_split_integrity.py @@ -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 + diff --git a/webui/static/credential-sets.js b/webui/static/credential-sets.js index deb09e9c..2745df7d 100644 --- a/webui/static/credential-sets.js +++ b/webui/static/credential-sets.js @@ -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 = ` -
-
-
-

Quick Switch

-

Choose which account each service uses for your profile. Set up by the admin.

-
- -
-
-
`; - 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 = '
Loading…
'; - 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 = '
Could not load.
'; - } -} - -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 = [ - ``, - ...info.options.map(o => ` - `), - ].join(''); - return ` -
-
${svc.name}
-
${pills}
-
`; - }).join(''); - body.innerHTML = rows || - '
No alternate accounts have been set up by the admin yet.
'; -} - -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 */ } -} diff --git a/webui/static/service-switch.js b/webui/static/service-switch.js new file mode 100644 index 00000000..9a33bfb0 --- /dev/null +++ b/webui/static/service-switch.js @@ -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 = ` + `; + 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 = '
Loading…
'; + 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 => ` + `).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 + ? `` + : `${emoji || '🎵'}`; + return ` + `; +} + +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; + 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 = `
Metadata source
${cards}
`; + } 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 = `
Media server
${cards}
`; + } else { + _ssRenderDownloadPanel(panel, d, editable); + } +} + +function _ssRenderDownloadPanel(panel, d, editable) { + const isHybrid = d.download.mode === 'hybrid'; + const toggle = ` +
+ + +
`; + + 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 = `
Drag to set priority — SoulSync tries each in order.
+
` + + order.map((id, i) => { + const info = _ssDownloadInfo(id); + return `
+ ${i + 1} + ${info.logo ? `` : `${info.emoji}`} + ${_ssEsc(info.name)} +
`; + }).join('') + `
`; + } 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 = `
${cards}
`; + } + panel.innerHTML = `
Download source
${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'); + } +} diff --git a/webui/static/style.css b/webui/static/style.css index 32c7cf4f..e205e946 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; }