Add global search bar with full enhanced search parity
Persistent Spotlight-style search bar at bottom-center, accessible from any page via click, /, or Ctrl+K. Hidden on Downloads page where enhanced search already exists. Features matching enhanced search: - Clear button when input has text - Source tabs with live switching - Source badges, library check, play buttons - Album click opens download modal directly - Artist click navigates to detail page - Tab switching stays open (timestamp guard) - Mobile responsive
This commit is contained in:
parent
0f0ec3acb8
commit
13629720e8
3 changed files with 748 additions and 0 deletions
|
|
@ -6791,6 +6791,17 @@
|
|||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4/dist/chart.umd.min.js"></script>
|
||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||
<!-- Notification bell + floating helper toggle — always accessible above modals -->
|
||||
<!-- Global Search Bar — Spotlight-style search from anywhere -->
|
||||
<div class="gsearch-bar" id="gsearch-bar">
|
||||
<div class="gsearch-icon">
|
||||
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"><circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/></svg>
|
||||
</div>
|
||||
<input type="text" class="gsearch-input" id="gsearch-input" placeholder="Search artists, albums, tracks..." autocomplete="off" spellcheck="false">
|
||||
<button class="gsearch-clear" id="gsearch-clear" style="display:none" title="Clear">×</button>
|
||||
<span class="gsearch-shortcut" id="gsearch-shortcut">/</span>
|
||||
</div>
|
||||
<div class="gsearch-results" id="gsearch-results"></div>
|
||||
|
||||
<button class="notif-bell-btn" id="notif-bell-btn" onclick="toggleNotifPanel()" title="Notifications">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 8A6 6 0 0 0 6 8c0 7-3 9-3 9h18s-3-2-3-9"/><path d="M13.73 21a2 2 0 0 1-3.46 0"/></svg>
|
||||
<span class="notif-bell-badge" id="notif-bell-badge" style="display:none">0</span>
|
||||
|
|
|
|||
|
|
@ -2743,6 +2743,9 @@ function navigateToPage(pageId) {
|
|||
|
||||
currentPage = pageId;
|
||||
|
||||
// Show/hide global search bar (hide on downloads page where enhanced search exists)
|
||||
if (typeof _gsUpdateVisibility === 'function') _gsUpdateVisibility();
|
||||
|
||||
// Show/hide discover download sidebar based on page
|
||||
const downloadSidebar = document.getElementById('discover-download-sidebar');
|
||||
if (downloadSidebar) {
|
||||
|
|
@ -16803,6 +16806,464 @@ function _notifTimeAgo(ts) {
|
|||
return `${Math.floor(h / 24)}d ago`;
|
||||
}
|
||||
|
||||
// ==================================================================================
|
||||
// GLOBAL SEARCH BAR — Spotlight-style search from anywhere
|
||||
// ==================================================================================
|
||||
|
||||
const _gsState = {
|
||||
active: false,
|
||||
query: '',
|
||||
data: null,
|
||||
sources: {},
|
||||
activeSource: null,
|
||||
abortCtrl: null,
|
||||
altAbortCtrl: null,
|
||||
debounceTimer: null,
|
||||
};
|
||||
|
||||
(function initGlobalSearch() {
|
||||
// Defer init until DOM is ready
|
||||
const _doInit = () => {
|
||||
const bar = document.getElementById('gsearch-bar');
|
||||
const input = document.getElementById('gsearch-input');
|
||||
const results = document.getElementById('gsearch-results');
|
||||
if (!input || !bar) return;
|
||||
|
||||
bar.addEventListener('click', () => input.focus());
|
||||
|
||||
input.addEventListener('focus', () => {
|
||||
bar.classList.add('active');
|
||||
_gsState.active = true;
|
||||
const shortcut = document.getElementById('gsearch-shortcut');
|
||||
if (shortcut) shortcut.style.display = 'none';
|
||||
if (_gsState.data && _gsState.query) _gsShowResults();
|
||||
});
|
||||
|
||||
// No blur handler — closing is handled by click-outside and Escape only
|
||||
// This prevents tab switching and result clicks from closing the panel
|
||||
|
||||
const clearBtn = document.getElementById('gsearch-clear');
|
||||
|
||||
input.addEventListener('input', () => {
|
||||
const q = input.value.trim();
|
||||
_gsState.query = q;
|
||||
if (clearBtn) clearBtn.style.display = q.length > 0 ? '' : 'none';
|
||||
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
|
||||
if (q.length < 2) { _gsHideResults(); return; }
|
||||
_gsState.debounceTimer = setTimeout(() => _gsPerformSearch(q), 300);
|
||||
});
|
||||
|
||||
if (clearBtn) {
|
||||
clearBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
input.value = '';
|
||||
_gsState.query = '';
|
||||
_gsState.data = null;
|
||||
clearBtn.style.display = 'none';
|
||||
_gsHideResults();
|
||||
input.focus();
|
||||
});
|
||||
}
|
||||
|
||||
input.addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
if (_gsState.debounceTimer) clearTimeout(_gsState.debounceTimer);
|
||||
const q = input.value.trim();
|
||||
if (q.length >= 2) _gsPerformSearch(q);
|
||||
} else if (e.key === 'Escape') {
|
||||
_gsDeactivate();
|
||||
input.blur();
|
||||
}
|
||||
});
|
||||
|
||||
// Keyboard shortcuts
|
||||
document.addEventListener('keydown', e => {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); input.focus(); return; }
|
||||
if (e.key === '/' && !['INPUT', 'TEXTAREA', 'SELECT'].includes(document.activeElement?.tagName)) { e.preventDefault(); input.focus(); }
|
||||
});
|
||||
|
||||
// Click outside to close — uses delayed check because tab clicks replace DOM
|
||||
document.addEventListener('click', e => {
|
||||
if (!_gsState.active) return;
|
||||
// Skip if click was recent interaction with search system (within 100ms of a switch)
|
||||
if (_gsState._lastInteraction && Date.now() - _gsState._lastInteraction < 200) return;
|
||||
setTimeout(() => {
|
||||
if (!_gsState.active) return;
|
||||
const freshBar = document.getElementById('gsearch-bar');
|
||||
const freshResults = document.getElementById('gsearch-results');
|
||||
const target = e.target;
|
||||
if (freshBar?.contains(target) || freshResults?.contains(target)) return;
|
||||
_gsDeactivate();
|
||||
}, 100);
|
||||
});
|
||||
|
||||
// Collapse on sidebar navigation + hide on downloads page
|
||||
document.addEventListener('click', e => {
|
||||
if (e.target.closest('.sidebar-link, .nav-item, .back-btn')) {
|
||||
if (_gsState.active) _gsDeactivate();
|
||||
// Check after navigation which page we're on
|
||||
setTimeout(_gsUpdateVisibility, 200);
|
||||
}
|
||||
});
|
||||
};
|
||||
if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', () => { _doInit(); _gsUpdateVisibility(); });
|
||||
else { _doInit(); setTimeout(_gsUpdateVisibility, 500); }
|
||||
})();
|
||||
|
||||
function _gsUpdateVisibility() {
|
||||
const bar = document.getElementById('gsearch-bar');
|
||||
if (!bar) return;
|
||||
// Hide on downloads page where enhanced search already exists
|
||||
const onDownloads = typeof currentPage !== 'undefined' && currentPage === 'downloads';
|
||||
bar.style.display = onDownloads ? 'none' : '';
|
||||
if (onDownloads && _gsState.active) _gsDeactivate();
|
||||
}
|
||||
|
||||
function _gsDeactivate() {
|
||||
console.log('[GSearch] _gsDeactivate called', new Error().stack.split('\n')[2]?.trim());
|
||||
const bar = document.getElementById('gsearch-bar');
|
||||
const shortcut = document.getElementById('gsearch-shortcut');
|
||||
if (bar) bar.classList.remove('active');
|
||||
if (shortcut) shortcut.style.display = '';
|
||||
_gsState.active = false;
|
||||
_gsHideResults();
|
||||
}
|
||||
|
||||
function _gsHideResults() {
|
||||
const r = document.getElementById('gsearch-results');
|
||||
if (r) r.classList.remove('visible');
|
||||
}
|
||||
|
||||
function _gsShowResults() {
|
||||
const r = document.getElementById('gsearch-results');
|
||||
if (r && r.innerHTML.trim()) r.classList.add('visible');
|
||||
}
|
||||
|
||||
async function _gsPerformSearch(query) {
|
||||
if (_gsState.abortCtrl) _gsState.abortCtrl.abort();
|
||||
if (_gsState.altAbortCtrl) _gsState.altAbortCtrl.abort();
|
||||
_gsState.abortCtrl = new AbortController();
|
||||
_gsState.altAbortCtrl = new AbortController();
|
||||
|
||||
const results = document.getElementById('gsearch-results');
|
||||
if (!results) return;
|
||||
|
||||
results.innerHTML = '<div class="gsearch-loading"><div class="server-search-spinner"></div>Searching...</div>';
|
||||
results.classList.add('visible');
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/enhanced-search', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: _gsState.abortCtrl.signal,
|
||||
});
|
||||
const data = await res.json();
|
||||
_gsState.data = data;
|
||||
_gsState.activeSource = data.primary_source || 'spotify';
|
||||
_gsState.sources = {};
|
||||
_gsState.sources[_gsState.activeSource] = {
|
||||
artists: data.spotify_artists || [],
|
||||
albums: data.spotify_albums || [],
|
||||
tracks: data.spotify_tracks || [],
|
||||
};
|
||||
|
||||
_gsRender(data);
|
||||
|
||||
// Async library ownership check — adds badges + swaps play buttons for library tracks
|
||||
setTimeout(() => _gsLibraryCheck(), 200);
|
||||
|
||||
// Fetch alternate sources
|
||||
const alts = data.alternate_sources || [];
|
||||
for (const src of alts) {
|
||||
if (src === _gsState.activeSource) continue;
|
||||
fetch(`/api/enhanced-search/source/${src}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ query }),
|
||||
signal: _gsState.altAbortCtrl.signal,
|
||||
}).then(r => r.json()).then(altData => {
|
||||
if (altData.available) { _gsState.sources[src] = altData; _gsRenderTabs(); }
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) {
|
||||
if (e.name !== 'AbortError') results.innerHTML = '<div class="gsearch-empty">Search failed</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function _gsRender(data) {
|
||||
const results = document.getElementById('gsearch-results');
|
||||
if (!results) return;
|
||||
|
||||
const src = _gsState.sources[_gsState.activeSource] || {};
|
||||
const dbArtists = data?.db_artists || [];
|
||||
const artists = src.artists || [];
|
||||
const allAlbums = src.albums || [];
|
||||
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
|
||||
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
|
||||
const tracks = src.tracks || [];
|
||||
const total = dbArtists.length + artists.length + albums.length + singles.length + tracks.length;
|
||||
|
||||
if (total === 0) {
|
||||
results.innerHTML = `<div class="gsearch-empty">No results for "${_escToast(_gsState.query)}"<br><span style="font-size:10px;opacity:0.5">Try different keywords or check spelling</span></div>`;
|
||||
results.classList.add('visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const sourceLabels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', hydrabase: 'Hydrabase' };
|
||||
const srcLabel = sourceLabels[_gsState.activeSource] || _gsState.activeSource || '';
|
||||
|
||||
let h = '';
|
||||
h += `<div class="gsearch-results-header"><span class="gsearch-results-title">Results</span><span class="gsearch-results-count">${total} items</span></div>`;
|
||||
h += '<div class="gsearch-tabs" id="gsearch-tabs"></div>';
|
||||
h += '<div class="gsearch-results-body">';
|
||||
|
||||
if (dbArtists.length) {
|
||||
h += '<div class="gsearch-section-header">📚 In Your Library</div><div class="gsearch-grid">';
|
||||
h += dbArtists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', true)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">Library</div></div></div>`).join('');
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
if (artists.length) {
|
||||
h += `<div class="gsearch-section-header">🎤 Artists <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
||||
h += artists.map(a => `<div class="gsearch-item" onclick="_gsClickArtist('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', false)"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎤'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div></div></div>`).join('');
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
const activeSrc = _gsState.activeSource || 'spotify';
|
||||
|
||||
if (albums.length) {
|
||||
h += `<div class="gsearch-section-header">💿 Albums <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
||||
h += albums.map(a => {
|
||||
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
|
||||
const yr = a.release_date ? a.release_date.substring(0, 4) : '';
|
||||
const img = (a.image_url || '').replace(/'/g, "\\'");
|
||||
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '💿'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${yr ? ` · ${yr}` : ''}</div></div></div>`;
|
||||
}).join('');
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
if (singles.length) {
|
||||
h += `<div class="gsearch-section-header">🎶 Singles & EPs <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-grid">`;
|
||||
h += singles.map(a => {
|
||||
const ar = a.artist || (a.artists ? a.artists.join(', ') : '');
|
||||
const img = (a.image_url || '').replace(/'/g, "\\'");
|
||||
return `<div class="gsearch-item" onclick="_gsClickAlbum('${a.id}', '${_escToast(a.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${img}', '${activeSrc}')"><div class="gsearch-item-art">${a.image_url ? `<img src="${a.image_url}" loading="lazy">` : '🎶'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(a.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}</div></div></div>`;
|
||||
}).join('');
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
if (tracks.length) {
|
||||
h += `<div class="gsearch-section-header">🎵 Tracks <span class="gsearch-source-badge">${srcLabel}</span></div><div class="gsearch-track-list">`;
|
||||
h += tracks.map(t => {
|
||||
const ar = t.artist || (t.artists ? t.artists.join(', ') : '');
|
||||
const dur = t.duration_ms ? `${Math.floor(t.duration_ms / 60000)}:${String(Math.floor((t.duration_ms % 60000) / 1000)).padStart(2, '0')}` : '';
|
||||
return `<div class="gsearch-track" onclick="_gsClickTrack('${_escToast(ar).replace(/'/g, "\\'")}', '${_escToast(t.name).replace(/'/g, "\\'")}')"><div class="gsearch-item-art" style="width:32px;height:32px;border-radius:6px">${t.image_url ? `<img src="${t.image_url}" loading="lazy">` : '🎵'}</div><div class="gsearch-item-info"><div class="gsearch-item-title">${_escToast(t.name)}</div><div class="gsearch-item-sub">${_escToast(ar)}${t.album ? ` · ${_escToast(t.album)}` : ''}</div></div><div class="gsearch-track-dur">${dur}</div><button class="gsearch-play-btn" onclick="event.stopPropagation(); _gsPlayTrack('${_escToast(t.name).replace(/'/g, "\\'")}', '${_escToast(ar).replace(/'/g, "\\'")}', '${_escToast(t.album || '').replace(/'/g, "\\'")}')" title="Stream">▶</button></div>`;
|
||||
}).join('');
|
||||
h += '</div>';
|
||||
}
|
||||
|
||||
h += '</div>';
|
||||
results.innerHTML = h;
|
||||
results.classList.add('visible');
|
||||
_gsRenderTabs();
|
||||
}
|
||||
|
||||
function _gsRenderTabs() {
|
||||
const el = document.getElementById('gsearch-tabs');
|
||||
if (!el) return;
|
||||
const sources = Object.keys(_gsState.sources);
|
||||
if (sources.length < 2) { el.style.display = 'none'; return; }
|
||||
const labels = { spotify: 'Spotify', itunes: 'Apple Music', deezer: 'Deezer', hydrabase: 'Hydrabase' };
|
||||
el.style.display = 'flex';
|
||||
el.innerHTML = sources.map(s => {
|
||||
const d = _gsState.sources[s];
|
||||
const c = (d.artists?.length || 0) + (d.albums?.length || 0) + (d.tracks?.length || 0);
|
||||
return `<button class="gsearch-tab${s === _gsState.activeSource ? ' active' : ''}" onclick="_gsSwitchSource('${s}')">${labels[s] || s} (${c})</button>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function _gsSwitchSource(src) {
|
||||
_gsState._lastInteraction = Date.now();
|
||||
_gsState.activeSource = src;
|
||||
_gsRender(_gsState.data);
|
||||
const input = document.getElementById('gsearch-input');
|
||||
if (input) input.focus();
|
||||
}
|
||||
|
||||
function _gsClickArtist(id, name, isLibrary) {
|
||||
_gsDeactivate();
|
||||
if (isLibrary) {
|
||||
// Same as enhanced search: navigateToArtistDetail
|
||||
navigateToArtistDetail(id, name);
|
||||
} else {
|
||||
// Same as enhanced search: navigate to Artists page + selectArtistForDetail
|
||||
navigateToPage('artists');
|
||||
setTimeout(() => {
|
||||
selectArtistForDetail({ id, name, image_url: '' }, {
|
||||
source: _gsState.activeSource || '',
|
||||
});
|
||||
}, 150);
|
||||
}
|
||||
}
|
||||
|
||||
async function _gsClickAlbum(albumId, albumName, artistName, imageUrl, source) {
|
||||
_gsDeactivate();
|
||||
// Same flow as handleEnhancedSearchAlbumClick — fetch album, open download modal
|
||||
showLoadingOverlay('Loading album...');
|
||||
try {
|
||||
const params = new URLSearchParams({ name: albumName, artist: artistName });
|
||||
if (source && source !== 'spotify') params.set('source', source);
|
||||
const response = await fetch(`/api/spotify/album/${albumId}?${params}`);
|
||||
if (!response.ok) throw new Error(`Failed to load album: ${response.status}`);
|
||||
const albumData = await response.json();
|
||||
|
||||
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
|
||||
hideLoadingOverlay();
|
||||
showToast(`No tracks available for "${albumName}"`, 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
const enrichedTracks = albumData.tracks.map(t => ({
|
||||
...t,
|
||||
album: { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks }
|
||||
}));
|
||||
|
||||
const virtualPlaylistId = `enhanced_search_album_${albumId}`;
|
||||
const firstArtist = (albumData.artists || [])[0] || {};
|
||||
const artistObj = { id: firstArtist.id || '', name: firstArtist.name || artistName, source: source || '' };
|
||||
const albumObj = { name: albumData.name, id: albumData.id, album_type: albumData.album_type || 'album', images: albumData.images || [], release_date: albumData.release_date, total_tracks: albumData.total_tracks, artists: albumData.artists || [{ name: artistName }] };
|
||||
|
||||
await openDownloadMissingModalForArtistAlbum(virtualPlaylistId, `[${artistName}] ${albumData.name}`, enrichedTracks, albumObj, artistObj, false);
|
||||
|
||||
} catch (e) {
|
||||
hideLoadingOverlay();
|
||||
showToast('Failed to load album: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function _gsClickTrack(artistName, trackName) {
|
||||
_gsDeactivate();
|
||||
navigateToPage('downloads');
|
||||
setTimeout(() => {
|
||||
const input = document.getElementById('enhanced-search-input');
|
||||
if (input) { input.value = `${artistName} ${trackName}`.trim(); input.dispatchEvent(new Event('input')); }
|
||||
}, 300);
|
||||
}
|
||||
|
||||
async function _gsPlayTrack(trackName, artistName, albumName) {
|
||||
try {
|
||||
showToast('Searching for stream...', 'info');
|
||||
const res = await fetch('/api/enhanced-search/stream-track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ track_name: trackName, artist_name: artistName, album_name: albumName })
|
||||
});
|
||||
const data = await res.json();
|
||||
if (data.success && data.result) {
|
||||
if (typeof startStream === 'function') {
|
||||
startStream(data.result);
|
||||
} else {
|
||||
showToast('Streaming not available', 'error');
|
||||
}
|
||||
} else {
|
||||
showToast(data.error || 'No stream found', 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Stream failed: ' + e.message, 'error');
|
||||
}
|
||||
}
|
||||
|
||||
// Async library check for global search results — adds badges + swaps play buttons
|
||||
async function _gsLibraryCheck() {
|
||||
try {
|
||||
const src = _gsState.sources[_gsState.activeSource] || {};
|
||||
const allAlbums = src.albums || [];
|
||||
const albums = allAlbums.filter(a => !a.album_type || a.album_type === 'album' || a.album_type === 'compilation');
|
||||
const singles = allAlbums.filter(a => a.album_type === 'single' || a.album_type === 'ep');
|
||||
const tracks = src.tracks || [];
|
||||
if (!allAlbums.length && !tracks.length) return;
|
||||
|
||||
const res = await fetch('/api/enhanced-search/library-check', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
albums: allAlbums.map(a => ({ name: a.name, artist: a.artist || (a.artists ? a.artists.join(', ') : '') })),
|
||||
tracks: tracks.map(t => ({ name: t.name, artist: t.artist || (t.artists ? t.artists.join(', ') : '') })),
|
||||
})
|
||||
});
|
||||
const checkData = await res.json();
|
||||
|
||||
// Add "In Library" badges to albums — match by index against allAlbums order
|
||||
const albumResults = checkData.albums || [];
|
||||
let albumIdx = 0;
|
||||
// Albums section
|
||||
document.querySelectorAll('#gsearch-results .gsearch-results-body').forEach(body => {
|
||||
// Find all gsearch-item elements and tag ones that are albums
|
||||
const sections = body.querySelectorAll('.gsearch-section-header');
|
||||
sections.forEach(header => {
|
||||
const text = header.textContent;
|
||||
const isAlbumSection = text.includes('Albums') || text.includes('Singles');
|
||||
if (!isAlbumSection) return;
|
||||
const grid = header.nextElementSibling;
|
||||
if (!grid) return;
|
||||
const items = grid.querySelectorAll('.gsearch-item');
|
||||
items.forEach(item => {
|
||||
if (albumIdx < albumResults.length && albumResults[albumIdx]) {
|
||||
if (!item.querySelector('.gsearch-item-badge')) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'gsearch-item-badge';
|
||||
badge.textContent = 'In Library';
|
||||
item.appendChild(badge);
|
||||
}
|
||||
}
|
||||
albumIdx++;
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// Tag tracks + swap play buttons for library playback
|
||||
const trackResults = checkData.tracks || [];
|
||||
const trackEls = document.querySelectorAll('#gsearch-results .gsearch-track');
|
||||
trackEls.forEach((el, i) => {
|
||||
const tr = trackResults[i];
|
||||
if (tr && tr.in_library) {
|
||||
// Add badge
|
||||
if (!el.querySelector('.gsearch-item-badge')) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'gsearch-item-badge';
|
||||
badge.textContent = 'In Library';
|
||||
badge.style.marginRight = '4px';
|
||||
el.querySelector('.gsearch-track-dur')?.before(badge);
|
||||
}
|
||||
|
||||
// Swap play button to library playback
|
||||
if (tr.file_path) {
|
||||
const playBtn = el.querySelector('.gsearch-play-btn');
|
||||
if (playBtn) {
|
||||
const newBtn = playBtn.cloneNode(true);
|
||||
newBtn.title = 'Play from library';
|
||||
newBtn.style.background = 'rgba(76,175,80,0.15)';
|
||||
newBtn.style.color = '#4caf50';
|
||||
newBtn.addEventListener('click', e => {
|
||||
e.stopPropagation();
|
||||
playLibraryTrack(
|
||||
{ id: tr.track_id, title: tr.title, file_path: tr.file_path },
|
||||
tr.album_title || '',
|
||||
tr.artist_name || ''
|
||||
);
|
||||
});
|
||||
playBtn.replaceWith(newBtn);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// Non-critical
|
||||
}
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
|
|
|
|||
|
|
@ -5169,6 +5169,282 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
}
|
||||
.notif-entry-link:hover { opacity: 0.7; }
|
||||
|
||||
/* ==================================================================================
|
||||
GLOBAL SEARCH BAR — Spotlight-style search from anywhere
|
||||
================================================================================== */
|
||||
|
||||
.gsearch-bar {
|
||||
position: fixed;
|
||||
bottom: 24px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 380px;
|
||||
height: 42px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 0 16px;
|
||||
background: rgba(18, 18, 26, 0.65);
|
||||
backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 21px;
|
||||
z-index: 99998;
|
||||
opacity: 0.55;
|
||||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
cursor: text;
|
||||
}
|
||||
.gsearch-bar:hover {
|
||||
opacity: 0.8;
|
||||
border-color: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(18, 18, 26, 0.8);
|
||||
}
|
||||
.gsearch-bar.active {
|
||||
opacity: 1;
|
||||
width: 560px;
|
||||
background: rgba(18, 18, 26, 0.92);
|
||||
backdrop-filter: blur(24px);
|
||||
border-color: rgba(var(--accent-rgb), 0.3);
|
||||
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4), 0 0 0 1px rgba(var(--accent-rgb), 0.08);
|
||||
}
|
||||
|
||||
.gsearch-icon {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-shrink: 0;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
.gsearch-bar.active .gsearch-icon { color: var(--accent); }
|
||||
|
||||
.gsearch-input {
|
||||
flex: 1;
|
||||
background: none;
|
||||
border: none;
|
||||
outline: none;
|
||||
color: #fff;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
min-width: 0;
|
||||
}
|
||||
.gsearch-input::placeholder { color: rgba(255, 255, 255, 0.25); }
|
||||
.gsearch-bar.active .gsearch-input::placeholder { color: rgba(255, 255, 255, 0.4); }
|
||||
|
||||
.gsearch-clear {
|
||||
width: 20px; height: 20px; border-radius: 50%; border: none;
|
||||
background: rgba(255,255,255,0.08); color: rgba(255,255,255,0.4);
|
||||
font-size: 14px; cursor: pointer; display: flex; align-items: center;
|
||||
justify-content: center; flex-shrink: 0; transition: all 0.15s; padding: 0;
|
||||
}
|
||||
.gsearch-clear:hover { background: rgba(255,255,255,0.15); color: #fff; }
|
||||
|
||||
.gsearch-shortcut {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 5px;
|
||||
padding: 2px 7px;
|
||||
flex-shrink: 0;
|
||||
transition: opacity 0.2s;
|
||||
}
|
||||
.gsearch-bar.active .gsearch-shortcut { opacity: 0; pointer-events: none; }
|
||||
|
||||
/* ── Results Panel ── */
|
||||
.gsearch-results {
|
||||
position: fixed;
|
||||
bottom: 76px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 620px;
|
||||
max-width: 95vw;
|
||||
max-height: 60vh;
|
||||
background: rgba(14, 14, 20, 0.97);
|
||||
backdrop-filter: blur(30px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||
border-radius: 18px;
|
||||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.6), 0 0 0 1px rgba(255, 255, 255, 0.03) inset;
|
||||
z-index: 99997;
|
||||
display: none;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.gsearch-results.visible { display: flex; animation: gsearchSlideUp 0.2s ease; }
|
||||
|
||||
@keyframes gsearchSlideUp {
|
||||
from { opacity: 0; transform: translateX(-50%) translateY(10px); }
|
||||
to { opacity: 1; transform: translateX(-50%) translateY(0); }
|
||||
}
|
||||
|
||||
.gsearch-results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 12px 18px 8px;
|
||||
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gsearch-results-title { font-size: 11px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; color: rgba(255,255,255,0.3); }
|
||||
.gsearch-results-count { font-size: 10px; color: rgba(255,255,255,0.2); }
|
||||
|
||||
.gsearch-results-body {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 8px 12px 14px;
|
||||
}
|
||||
.gsearch-results-body::-webkit-scrollbar { width: 4px; }
|
||||
.gsearch-results-body::-webkit-scrollbar-thumb { background: rgba(255,255,255,0.08); border-radius: 2px; }
|
||||
|
||||
/* Source tabs */
|
||||
.gsearch-tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: 8px 18px 4px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.gsearch-tab {
|
||||
padding: 5px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid rgba(255, 255, 255, 0.06);
|
||||
background: rgba(255, 255, 255, 0.03);
|
||||
color: rgba(255, 255, 255, 0.45);
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.gsearch-tab.active { background: rgba(var(--accent-rgb), 0.12); color: var(--accent); border-color: rgba(var(--accent-rgb), 0.2); }
|
||||
.gsearch-tab:hover:not(.active) { background: rgba(255,255,255,0.06); }
|
||||
|
||||
/* Section headers */
|
||||
.gsearch-section-header {
|
||||
font-size: 10px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: rgba(255, 255, 255, 0.25);
|
||||
padding: 10px 6px 6px;
|
||||
}
|
||||
|
||||
/* Result items — compact grid */
|
||||
.gsearch-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.gsearch-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 8px 10px;
|
||||
border-radius: 10px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.gsearch-item:hover { background: rgba(255, 255, 255, 0.05); }
|
||||
|
||||
.gsearch-item-art {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
flex-shrink: 0;
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
color: rgba(255, 255, 255, 0.15);
|
||||
}
|
||||
.gsearch-item-art img { width: 100%; height: 100%; object-fit: cover; }
|
||||
|
||||
.gsearch-item-info { flex: 1; min-width: 0; }
|
||||
.gsearch-item-title { font-size: 12px; font-weight: 600; color: rgba(255,255,255,0.85); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
|
||||
.gsearch-item-sub { font-size: 10px; color: rgba(255,255,255,0.35); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; margin-top: 1px; }
|
||||
|
||||
.gsearch-item-badge {
|
||||
font-size: 8px; font-weight: 700; text-transform: uppercase;
|
||||
padding: 2px 6px; border-radius: 4px;
|
||||
background: rgba(76,175,80,0.12); color: #4caf50;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.gsearch-loading {
|
||||
text-align: center; padding: 24px; font-size: 12px; color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
.gsearch-empty {
|
||||
text-align: center; padding: 24px; font-size: 12px; color: rgba(255,255,255,0.2);
|
||||
}
|
||||
|
||||
/* Track list (not grid) */
|
||||
.gsearch-track-list { display: flex; flex-direction: column; }
|
||||
|
||||
.gsearch-track {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
cursor: pointer;
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.gsearch-track:hover { background: rgba(255, 255, 255, 0.04); }
|
||||
|
||||
.gsearch-track-dur {
|
||||
font-size: 10px; color: rgba(255,255,255,0.2);
|
||||
flex-shrink: 0; min-width: 32px; text-align: right;
|
||||
}
|
||||
|
||||
.gsearch-play-btn {
|
||||
width: 26px; height: 26px; border-radius: 50%; border: none;
|
||||
background: rgba(var(--accent-rgb), 0.12); color: var(--accent);
|
||||
font-size: 10px; cursor: pointer; display: flex; align-items: center;
|
||||
justify-content: center; flex-shrink: 0; transition: all 0.15s;
|
||||
opacity: 0;
|
||||
}
|
||||
.gsearch-track:hover .gsearch-play-btn { opacity: 1; }
|
||||
.gsearch-play-btn:hover { background: var(--accent); color: #fff; transform: scale(1.1); }
|
||||
|
||||
.gsearch-source-badge {
|
||||
font-size: 9px; font-weight: 600; padding: 1px 6px; border-radius: 4px;
|
||||
background: rgba(var(--accent-rgb), 0.1); color: var(--accent);
|
||||
margin-left: 6px; vertical-align: middle;
|
||||
}
|
||||
|
||||
/* ── Global Search Mobile ── */
|
||||
@media (max-width: 768px) {
|
||||
.gsearch-bar {
|
||||
width: calc(100% - 48px);
|
||||
left: 24px;
|
||||
right: 24px;
|
||||
transform: none;
|
||||
bottom: 80px;
|
||||
height: 40px;
|
||||
}
|
||||
.gsearch-bar.active {
|
||||
width: calc(100% - 32px);
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
}
|
||||
.gsearch-results {
|
||||
width: calc(100% - 32px);
|
||||
left: 16px;
|
||||
right: 16px;
|
||||
transform: none;
|
||||
bottom: 130px;
|
||||
max-height: 50vh;
|
||||
border-radius: 14px;
|
||||
}
|
||||
.gsearch-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.gsearch-shortcut { display: none; }
|
||||
.gsearch-item-art { width: 36px; height: 36px; }
|
||||
.gsearch-tabs { flex-wrap: wrap; }
|
||||
}
|
||||
|
||||
/* ── Wing It Button ── */
|
||||
/* ── Wing It Button + Dropdown ── */
|
||||
.wing-it-wrap {
|
||||
|
|
|
|||
Loading…
Reference in a new issue