diff --git a/webui/static/downloads.js b/webui/static/downloads.js index b82e8c76..a772d9e5 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -5022,10 +5022,14 @@ const _gsState = { sources: {}, // src -> {artists, albums, tracks, videos, db_artists, ...} fallbacks: {}, // src -> actual source served when backend fell back (rate-limit) loadingSources: new Set(), + configuredSources: {}, // src -> bool, populated from /api/settings/config-status abortCtrl: null, debounceTimer: null, _defaultSourceResolved: false, }; +// Permissive initial map — replaced by the real config-status lookup on +// first popover open. Prevents a flash of "all unconfigured" icons. +for (const _src of SOURCE_ORDER) _gsState.configuredSources[_src] = true; (function initGlobalSearch() { // Defer init until DOM is ready @@ -5160,6 +5164,16 @@ async function _gsInitDefaultSource() { } } catch (_) { /* best-effort */ } if (!SOURCE_LABELS[_gsState.activeSource]) _gsState.activeSource = 'spotify'; + // Fetch per-source configured state so unconfigured icons render dimmed. + try { + _gsState.configuredSources = await fetchSourceConfiguredMap(); + } catch (_) { /* keep optimistic default */ } + // If the configured primary is itself unconfigured, fall forward to the + // first configured source so the default active icon is actually usable. + if (_gsState.configuredSources[_gsState.activeSource] === false) { + const firstConfigured = SOURCE_ORDER.find(s => _gsState.configuredSources[s] !== false); + if (firstConfigured) _gsState.activeSource = firstConfigured; + } } async function _gsPerformSearch(query) { @@ -5296,14 +5310,21 @@ function _gsSourceRowHtml() { const cached = !!_gsState.sources[src]; const loading = _gsState.loadingSources.has(src); const fallback = _gsState.fallbacks[src]; + const configured = _gsState.configuredSources[src] !== false; const classes = ['gsearch-source-icon', active ? 'active' : '', cached ? 'cached' : '', loading ? 'loading' : '', - fallback ? 'fallback-warning' : ''].filter(Boolean).join(' '); - const title = fallback - ? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}` - : info.text; + fallback ? 'fallback-warning' : '', + configured ? '' : 'unconfigured'].filter(Boolean).join(' '); + let title; + if (!configured) { + title = `${info.text} — set up in Settings`; + } else if (fallback) { + title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`; + } else { + title = info.text; + } const glyph = loading ? '⏳' : (info.logo @@ -5487,6 +5508,13 @@ function _gsSetActiveSource(src) { if (!SOURCE_LABELS[src]) return; _gsState._lastInteraction = Date.now(); + // Not configured — jump to Settings instead of firing a doomed search. + if (_gsState.configuredSources[src] === false) { + _gsDeactivate(); + openSettingsForSource(src); + return; + } + // Soulseek — hand off to the Search page since its raw file results need // more room than the popover provides. if (src === 'soulseek') { diff --git a/webui/static/search.js b/webui/static/search.js index e3fe134a..73e781b8 100644 --- a/webui/static/search.js +++ b/webui/static/search.js @@ -73,6 +73,13 @@ function initializeSearchModeToggle() { loadingSources: new Set(), }; + // Which sources have credentials saved. Populated asynchronously on init + // via /api/settings/config-status. Unconfigured sources render dimmed + // and clicking them redirects to Settings → Connections instead of + // firing a search. + let _configuredSources = {}; + for (const src of SOURCE_ORDER) _configuredSources[src] = true; // optimistic default + // Initialize enhanced search const enhancedInput = document.getElementById('enhanced-search-input'); const enhancedSearchBtn = document.getElementById('enhanced-search-btn'); @@ -96,16 +103,23 @@ function initializeSearchModeToggle() { const cached = !!_cachedData.sources[src]; const loading = _cachedData.loadingSources.has(src); const fallback = _cachedData.fallbacks[src]; + const configured = _configuredSources[src] !== false; const classes = [ 'enh-source-icon', active ? 'active' : '', cached ? 'cached' : '', loading ? 'loading' : '', fallback ? 'fallback-warning' : '', + configured ? '' : 'unconfigured', ].filter(Boolean).join(' '); - const title = fallback - ? `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}` - : info.text; + let title; + if (!configured) { + title = `${info.text} — set up in Settings`; + } else if (fallback) { + title = `${info.text} unavailable — served from ${(SOURCE_LABELS[fallback] || {}).text || fallback}`; + } else { + title = info.text; + } // Prefer the brand logo when available; fall back to the emoji. // Spinner glyph overrides both while loading. const glyph = loading @@ -144,6 +158,20 @@ function initializeSearchModeToggle() { } } catch (_) { /* settings fetch best-effort */ } if (!SOURCE_LABELS[currentSearchSource]) currentSearchSource = 'spotify'; + // Pull per-source configured state in parallel so the dimmed icons + // don't flash on first render. Errors fall through to the optimistic + // default set at init. + try { + _configuredSources = await fetchSourceConfiguredMap(); + } catch (_) { /* keep optimistic default */ } + // If the configured primary turns out to be unconfigured (e.g. + // spotify saved as primary but the user never entered credentials), + // bump to the first source that is configured so the default click + // doesn't land on a dimmed "set up" icon. + if (_configuredSources[currentSearchSource] === false) { + const firstConfigured = SOURCE_ORDER.find(s => _configuredSources[s] !== false); + if (firstConfigured) currentSearchSource = firstConfigured; + } renderSourceRow(); } _initDefaultSource(); @@ -151,6 +179,15 @@ function initializeSearchModeToggle() { // ── Source selection ─────────────────────────────────────────────── function setActiveSource(src) { if (!SOURCE_LABELS[src]) return; + + // Not configured — jump to the relevant card in Settings rather than + // firing a search that can't succeed. Don't swap activeSource so the + // user's previous pick stays current when they come back. + if (_configuredSources[src] === false) { + openSettingsForSource(src); + return; + } + if (src === currentSearchSource) return; currentSearchSource = src; diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 662f881e..87062be1 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -85,6 +85,56 @@ const SOURCE_ORDER = [ 'youtube_videos', 'soulseek', ]; +// Sources the config-status endpoint doesn't cover because they don't need +// user-supplied credentials — they always render as "configured" in the picker. +const _ALWAYS_CONFIGURED_SOURCES = new Set(['musicbrainz', 'youtube_videos', 'soulseek']); + +// Fetch /api/settings/config-status and return a map { src -> bool } +// covering every source in SOURCE_ORDER. Sources not present in the backend +// registry (musicbrainz / youtube_videos / soulseek) are reported as +// configured so the picker doesn't dim always-available sources. +async function fetchSourceConfiguredMap() { + const map = {}; + try { + const resp = await fetch('/api/settings/config-status'); + if (resp.ok) { + const data = await resp.json(); + for (const src of SOURCE_ORDER) { + if (_ALWAYS_CONFIGURED_SOURCES.has(src)) { + map[src] = true; + } else { + map[src] = !!(data[src] && data[src].configured); + } + } + return map; + } + } catch (_) { /* fall through to conservative default */ } + // Network / endpoint failure — be permissive rather than dim everything. + for (const src of SOURCE_ORDER) map[src] = true; + return map; +} + +// Navigate to Settings → Connections tab and scroll to the service card that +// matches the picker's source id. Called when a user clicks an unconfigured +// source icon. +function openSettingsForSource(src) { + if (typeof navigateToPage !== 'function') return; + navigateToPage('settings'); + setTimeout(() => { + try { + if (typeof switchSettingsTab === 'function') switchSettingsTab('connections'); + } catch (_) { /* best-effort */ } + setTimeout(() => { + const card = document.querySelector(`#settings-page .stg-service[data-service="${src}"]`); + if (card) { + card.scrollIntoView({ behavior: 'smooth', block: 'center' }); + card.classList.add('stg-service-flash'); + setTimeout(() => card.classList.remove('stg-service-flash'), 2200); + } + }, 120); + }, 60); +} + // Render a single enhanced-search result section (artists / albums / tracks). // Shared between the Search page and the global widget. The mapItem callback // projects each backend item to the card config consumed here. diff --git a/webui/static/style.css b/webui/static/style.css index f3aeefc1..ada5555e 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -5624,6 +5624,39 @@ body.helper-mode-active #dashboard-activity-feed:hover { box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25); } +/* Same unconfigured treatment as the Search page icons. */ +.gsearch-source-icon.unconfigured { + opacity: 0.42; + filter: grayscale(0.7); + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); +} +.gsearch-source-icon.unconfigured:hover { + opacity: 0.75; + filter: grayscale(0.35); + transform: none; + box-shadow: none; + border-color: rgba(255, 255, 255, 0.12); +} +.gsearch-source-icon.unconfigured.active { + background: rgba(255, 255, 255, 0.02); + box-shadow: none; + transform: none; +} + +/* Flash highlight on the Settings service card after scrolling to it via + the picker. Two and a half seconds of gentle accent pulse so the user's + eye catches the card. */ +.stg-service.stg-service-flash { + animation: stg-service-flash-anim 2.2s ease-out; +} +@keyframes stg-service-flash-anim { + 0% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0.55); } + 35% { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.25); } + 100% { box-shadow: 0 0 0 0 rgba(var(--accent-rgb), 0); } +} + .gsearch-fallback-banner { padding: 6px 14px; margin: 0 12px 6px; @@ -33837,6 +33870,31 @@ div.artist-hero-badge { box-shadow: 0 0 0 1px rgba(250, 176, 5, 0.25); } +/* Unconfigured — no credentials saved for this source. The chip still + clicks (redirects to Settings → Connections), but looks muted so the + user's eye is drawn to the sources that actually work. */ +.enh-source-icon.unconfigured { + opacity: 0.42; + filter: grayscale(0.7); + background: rgba(255, 255, 255, 0.02); + border-color: rgba(255, 255, 255, 0.05); + color: rgba(255, 255, 255, 0.5); +} +.enh-source-icon.unconfigured:hover { + opacity: 0.75; + filter: grayscale(0.35); + transform: none; + box-shadow: none; + border-color: rgba(255, 255, 255, 0.12); +} +/* Kill brand glow / active gradient if an unconfigured source is somehow + marked active (defensive — setActiveSource bails before this normally). */ +.enh-source-icon.unconfigured.active { + background: rgba(255, 255, 255, 0.02); + box-shadow: none; + transform: none; +} + /* Rate-limit fallback banner above the enhanced results. */ .enh-fallback-banner { padding: 8px 12px;