Source picker: dim unconfigured sources, redirect to Settings on click
The picker used to render every source whether or not the user had credentials for it. Clicking Discogs with no token, Hydrabase with no URL, or Spotify with nothing saved would fire a doomed fetch — at best a silent empty state, at worst a confusing fallback to another source. Now the picker reads /api/settings/config-status (the same endpoint the Settings → Connections page already uses for the green/yellow status dot) on init and dims icons whose service isn't set up. Clicking a dimmed icon navigates to Settings → Connections and scrolls to the relevant service card with a brief accent-coloured pulse to orient the user. Sources the backend's SERVICE_CONFIG_REGISTRY doesn't cover (musicbrainz, youtube_videos, soulseek) are permanently treated as configured — they need no user credentials, so dimming them would mislead. Extra guard: if the user's configured primary metadata source is itself unconfigured (Spotify saved as primary but no client_id yet), `_initDefaultSource` falls forward to the first configured source so the default active icon is never a "set up" chip. Shared helpers: - fetchSourceConfiguredMap() centralizes the config-status lookup for both surfaces. Falls back permissively if the endpoint fails so the picker never stops working over a network hiccup. - openSettingsForSource(src) navigates to Settings → Connections and scrolls to `[data-service=src]`, pulsing a 2.2s accent flash (.stg-service-flash) so the user doesn't lose their place. CSS: - .unconfigured: 42% opacity, 0.7 grayscale filter, subdued hover state with no transform/glow (feels "look but don't touch"), defensive override to kill brand glow if somehow active. - @keyframes stg-service-flash-anim for the scroll-to highlight.
This commit is contained in:
parent
ec0c425e71
commit
c605904a5c
4 changed files with 180 additions and 7 deletions
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue