diff --git a/core/acoustid_client.py b/core/acoustid_client.py index 58680f8d..81db2be7 100644 --- a/core/acoustid_client.py +++ b/core/acoustid_client.py @@ -422,7 +422,7 @@ class AcoustIDClient: 'artist': artist, 'score': score, }) - logger.info(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})") + logger.debug(f"Found match: {title} by {artist} (MBID: {recording_id}, score: {score})") if not recordings: logger.info(f"No AcoustID matches found for: {audio_file}") diff --git a/core/audiodb_worker.py b/core/audiodb_worker.py index 1588a919..0b9b0f85 100644 --- a/core/audiodb_worker.py +++ b/core/audiodb_worker.py @@ -248,6 +248,7 @@ class AudioDBWorker: def _normalize_name(self, name: str) -> str: """Normalize artist name for comparison""" name = name.lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() diff --git a/core/deezer_worker.py b/core/deezer_worker.py index fa393d3d..7d085c7c 100644 --- a/core/deezer_worker.py +++ b/core/deezer_worker.py @@ -248,6 +248,7 @@ class DeezerWorker: def _normalize_name(self, name: str) -> str: """Normalize name for comparison""" name = name.lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() diff --git a/core/genius_worker.py b/core/genius_worker.py index f1bfd7a9..65d3b022 100644 --- a/core/genius_worker.py +++ b/core/genius_worker.py @@ -238,6 +238,7 @@ class GeniusWorker: def _normalize_name(self, name: str) -> str: """Normalize name for comparison""" name = name.lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'\s*\[.*?\]\s*', ' ', name) # Also strip brackets (Genius uses these) name = re.sub(r'\s*feat\.?\s+.*$', '', name) # Strip featuring diff --git a/core/itunes_worker.py b/core/itunes_worker.py index 698c4111..52f101ff 100644 --- a/core/itunes_worker.py +++ b/core/itunes_worker.py @@ -902,6 +902,7 @@ class iTunesWorker: def _normalize_name(self, name: str) -> str: name = name.lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() diff --git a/core/lastfm_worker.py b/core/lastfm_worker.py index 8a30d11b..48a61908 100644 --- a/core/lastfm_worker.py +++ b/core/lastfm_worker.py @@ -264,6 +264,7 @@ class LastFMWorker: def _normalize_name(self, name: str) -> str: """Normalize name for comparison""" name = name.lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() diff --git a/core/qobuz_worker.py b/core/qobuz_worker.py index 121f5ac9..2bea8bbd 100644 --- a/core/qobuz_worker.py +++ b/core/qobuz_worker.py @@ -271,6 +271,7 @@ class QobuzWorker: def _normalize_name(self, name: str) -> str: """Normalize name for comparison""" name = name.lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() diff --git a/core/spotify_worker.py b/core/spotify_worker.py index b4d07407..94e13138 100644 --- a/core/spotify_worker.py +++ b/core/spotify_worker.py @@ -945,7 +945,8 @@ class SpotifyWorker: def _normalize_name(self, name: str) -> str: name = name.lower().strip() - name = re.sub(r'\s*\(.*?\)\s*', ' ', name) + name = re.sub(r'\s+[-–—]\s+.*$', '', name) # Strip " - Remix/Edit/etc" suffixes (Spotify format) + name = re.sub(r'\s*\(.*?\)\s*', ' ', name) # Strip "(Remix/Edit/etc)" parentheticals name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() return name diff --git a/core/tidal_worker.py b/core/tidal_worker.py index 464f0675..1f47959d 100644 --- a/core/tidal_worker.py +++ b/core/tidal_worker.py @@ -283,6 +283,7 @@ class TidalWorker: def _normalize_name(self, name: str) -> str: """Normalize name for comparison""" name = name.lower().strip() + name = re.sub(r'\s+[-–—]\s+.*$', '', name) name = re.sub(r'\s*\(.*?\)\s*', ' ', name) name = re.sub(r'[^\w\s]', '', name) name = re.sub(r'\s+', ' ', name).strip() diff --git a/webui/static/helper.js b/webui/static/helper.js index 47718fc4..f05acd38 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -5,10 +5,11 @@ // ── State ──────────────────────────────────────────────────────────────── const HelperState = { - mode: null, // null | 'info' | 'tour' + mode: null, // null | 'info' | 'tour' | 'search' | 'shortcuts' | 'setup' | 'whats-new' | 'troubleshoot' menuOpen: false, tourStep: 0, tourId: null, + setupData: null, }; let helperModeActive = false; @@ -16,6 +17,10 @@ let _helperPopover = null; let _helperHighlighted = null; let _helperMenu = null; let _tourOverlay = null; +let _setupPanel = null; +let _shortcutsOverlay = null; +let _helperSearchPanel = null; +let _troubleshootActive = false; // ── Content Database ───────────────────────────────────────────────────── // Keys: CSS selectors matched via element.matches() @@ -221,7 +226,11 @@ const HELPER_CONTENT = { 'Response time indicates network latency to the service', 'If stuck on "Checking...", the service may be rate-limited' ], - docsId: 'gs-connecting' + docsId: 'gs-connecting', + actions: [ + { label: 'Open Settings', onClick: () => navigateToPage('settings') }, + { label: 'View Docs', onClick: () => _navigateToDocsSection('gs-connecting') } + ] }, '#media-server-service-card': { title: 'Media Server Status', @@ -231,7 +240,11 @@ const HELPER_CONTENT = { 'Select your Music Library in Settings after first connecting', 'Navidrome auto-detects new files — no scan trigger needed' ], - docsId: 'set-media' + docsId: 'set-media', + actions: [ + { label: 'Open Settings', onClick: () => navigateToPage('settings') }, + { label: 'View Docs', onClick: () => _navigateToDocsSection('set-media') } + ] }, '#soulseek-service-card': { title: 'Download Source Status', @@ -241,7 +254,11 @@ const HELPER_CONTENT = { 'Soulseek requires a running slskd instance with API key', 'Streaming sources (Tidal, Qobuz) need active subscriptions' ], - docsId: 'search-sources' + docsId: 'search-sources', + actions: [ + { label: 'Open Settings', onClick: () => { navigateToPage('settings'); setTimeout(() => typeof switchSettingsTab === 'function' && switchSettingsTab('downloads'), 400); } }, + { label: 'View Docs', onClick: () => _navigateToDocsSection('search-sources') } + ] }, // ─── DASHBOARD: SYSTEM STATS ──────────────────────────────────── @@ -2135,8 +2152,13 @@ function _navigateToDocsSection(docsId) { // ═══════════════════════════════════════════════════════════════════════════ const HELPER_MENU_ITEMS = [ - { id: 'info', icon: '🎯', label: 'Element Info', desc: 'Click any element to learn about it' }, - { id: 'tour', icon: '🚶', label: 'Guided Tour', desc: 'Step-by-step walkthrough' }, + { id: 'info', icon: '🎯', label: 'Element Info', desc: 'Click any element to learn about it' }, + { id: 'tour', icon: '🚶', label: 'Guided Tour', desc: 'Step-by-step walkthrough' }, + { id: 'search', icon: '🔍', label: 'Search Help', desc: 'Find answers fast' }, + { id: 'shortcuts', icon: '⌨️', label: 'Shortcuts', desc: 'Keyboard reference' }, + { id: 'setup', icon: '📋', label: 'Setup Progress', desc: 'Onboarding checklist' }, + { id: 'whats-new', icon: '✨', label: "What's New", desc: 'Latest features' }, + { id: 'troubleshoot', icon: '🔧', label: 'Troubleshoot', desc: 'Fix common issues' }, ]; function toggleHelperMode() { @@ -2154,6 +2176,21 @@ function toggleHelperMode() { openHelperMenu(); } +// Map page IDs → tour IDs (only where they differ) +const PAGE_TOUR_MAP = { + 'dashboard': 'dashboard', + 'sync': 'sync-playlist', + 'downloads': 'first-download', + 'discover': 'discover', + 'artists': 'artists-browse', + 'automations': 'automations', + 'library': 'library', + 'stats': 'stats', + 'import': 'import-music', + 'settings': 'settings-tour', + 'issues': 'issues-tour', +}; + function openHelperMenu() { closeHelperMenu(); HelperState.menuOpen = true; @@ -2162,10 +2199,29 @@ function openHelperMenu() { if (!floatBtn) return; floatBtn.classList.add('menu-open'); + // Detect current page for contextual tour suggestion + const currentPage = document.querySelector('.page.active')?.id?.replace('-page', '') || ''; + const suggestedTourId = PAGE_TOUR_MAP[currentPage]; + const suggestedTour = suggestedTourId ? HELPER_TOURS[suggestedTourId] : null; + const menu = document.createElement('div'); menu.className = 'helper-menu'; - menu.innerHTML = HELPER_MENU_ITEMS.map((item, i) => ` - +
+ `; + } + + const offset = suggestedTour ? 1 : 0; + menu.innerHTML = contextualBtn + HELPER_MENU_ITEMS.map((item, i) => ` + @@ -2212,11 +2268,17 @@ function activateHelperMode(mode) { const floatBtn = document.getElementById('helper-float-btn'); if (floatBtn) floatBtn.classList.add('active'); - if (mode === 'info') { - helperModeActive = true; - document.body.classList.add('helper-mode-active'); - } else if (mode === 'tour') { - openTourSelector(); + switch (mode) { + case 'info': + helperModeActive = true; + document.body.classList.add('helper-mode-active'); + break; + case 'tour': openTourSelector(); break; + case 'search': openHelperSearch(); break; + case 'shortcuts': openShortcutsOverlay(); break; + case 'setup': openSetupPanel(); break; + case 'whats-new': openWhatsNew(); break; + case 'troubleshoot': activateTroubleshootMode(); break; } } @@ -2226,6 +2288,10 @@ function exitHelperMode() { document.body.classList.remove('helper-mode-active'); dismissHelperPopover(); dismissTour(); + closeSetupPanel(); + closeShortcutsOverlay(); + closeHelperSearch(); + closeTroubleshootMode(); const floatBtn = document.getElementById('helper-float-btn'); if (floatBtn) floatBtn.classList.remove('active'); @@ -2703,7 +2769,7 @@ document.addEventListener('keydown', function(e) { if (e.key === 'Escape') { if (_helperPopover) { dismissHelperPopover(); return; } if (HelperState.tourId) { dismissTour(); return; } - if (helperModeActive) { exitHelperMode(); return; } + if (HelperState.mode) { exitHelperMode(); return; } if (HelperState.menuOpen) { closeHelperMenu(); return; } } // Arrow keys for tour navigation @@ -2711,6 +2777,21 @@ document.addEventListener('keydown', function(e) { if (e.key === 'ArrowRight' || e.key === 'ArrowDown') { e.preventDefault(); nextTourStep(); } if (e.key === 'ArrowLeft' || e.key === 'ArrowUp') { e.preventDefault(); prevTourStep(); } } + // ? opens helper menu (when not typing in an input) + if (e.key === '?' && !e.ctrlKey && !e.metaKey) { + const tag = document.activeElement?.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return; + if (document.activeElement?.isContentEditable) return; + e.preventDefault(); + toggleHelperMode(); + } + // Ctrl+K / Cmd+K opens helper search + if (e.key === 'k' && (e.ctrlKey || e.metaKey)) { + e.preventDefault(); + if (HelperState.mode === 'search') { exitHelperMode(); return; } + if (HelperState.mode) exitHelperMode(); + activateHelperMode('search'); + } }); // ═══════════════════════════════════════════════════════════════════════════ @@ -2742,6 +2823,13 @@ function showHelperPopover(targetEl, content) { `; } + let actionsHtml = ''; + if (content.actions && content.actions.length) { + actionsHtml = `
+ ${content.actions.map(a => ``).join('')} +
`; + } + popover.innerHTML = `
@@ -2750,9 +2838,20 @@ function showHelperPopover(targetEl, content) {
${content.description}
${tipsHtml} + ${actionsHtml} ${docsLink} `; + // Bind action click handlers + if (content.actions && content.actions.length) { + popover.querySelectorAll('.helper-action-btn').forEach((btn, i) => { + btn.addEventListener('click', () => { + exitHelperMode(); + content.actions[i].onClick(); + }); + }); + } + document.body.appendChild(popover); _helperPopover = popover; requestAnimationFrame(() => positionPopover(popover, targetEl)); @@ -2799,3 +2898,891 @@ function dismissHelperPopover() { _helperHighlighted = null; } } + +// ═══════════════════════════════════════════════════════════════════════════ +// SETUP PROGRESS TRACKER (Phase 2) +// ═══════════════════════════════════════════════════════════════════════════ + +const SETUP_STEPS = [ + { id: 'metadata-source', label: 'Connect Metadata Source', desc: 'Spotify, iTunes, or Deezer for album/artist info', icon: '🎵', page: 'settings' }, + { id: 'media-server', label: 'Connect Media Server', desc: 'Plex, Jellyfin, or Navidrome', icon: '🖥️', page: 'settings' }, + { id: 'download-source', label: 'Set Up Download Source', desc: 'Soulseek, YouTube, Tidal, Qobuz, HiFi, or Deezer', icon: '⬇️', page: 'settings', settingsTab: 'downloads' }, + { id: 'download-paths', label: 'Configure Download Paths', desc: 'Where music is saved and organized', icon: '📁', page: 'settings', settingsTab: 'downloads' }, + { id: 'first-scan', label: 'Run First Library Scan', desc: 'Import your existing collection from media server', icon: '🔍', page: 'dashboard', selector: '#db-updater-card' }, + { id: 'first-download', label: 'Download Your First Track', desc: 'Search for and download something', icon: '🎶', page: 'downloads' }, + { id: 'watchlist', label: 'Add an Artist to Watchlist', desc: 'Monitor for new releases automatically', icon: '👁️', page: 'library' }, + { id: 'automation', label: 'Create an Automation', desc: 'Schedule tasks and build workflows', icon: '🤖', page: 'automations' }, +]; + +function _getSetupCompletion() { + return JSON.parse(localStorage.getItem('soulsync_setup') || '{}'); +} + +function _markSetupComplete(stepId) { + const stored = _getSetupCompletion(); + stored[stepId] = Date.now(); + localStorage.setItem('soulsync_setup', JSON.stringify(stored)); +} + +async function _checkSetupStatus() { + const completion = _getSetupCompletion(); + const results = { ...completion }; + + // ── /status — checks services (spotify, media_server, soulseek) ───── + try { + const resp = await fetch('/status'); + if (resp.ok) { + const data = await resp.json(); + // Metadata source: spotify.connected is always true (iTunes fallback), check .source + if (data.spotify?.connected && data.spotify?.source) { + results['metadata-source'] = results['metadata-source'] || Date.now(); + _markSetupComplete('metadata-source'); + } + // Media server: single object, not per-server keys + if (data.media_server?.connected) { + results['media-server'] = results['media-server'] || Date.now(); + _markSetupComplete('media-server'); + } + // Download source + if (data.soulseek?.connected) { + results['download-source'] = results['download-source'] || Date.now(); + _markSetupComplete('download-source'); + } + } + } catch (e) { /* API unavailable — use cached */ } + + // ── /api/settings — checks download paths (nested under soulseek.*) ─ + try { + const resp = await fetch('/api/settings'); + if (resp.ok) { + const cfg = await resp.json(); + if (cfg.soulseek?.download_path || cfg.soulseek?.transfer_path) { + results['download-paths'] = results['download-paths'] || Date.now(); + _markSetupComplete('download-paths'); + } + } + } catch (e) { /* skip */ } + + // ── /api/library/artists — checks if library has been scanned ──────── + if (!results['first-scan']) { + try { + const resp = await fetch('/api/library/artists?page=1&limit=1'); + if (resp.ok) { + const data = await resp.json(); + if (data.total_count > 0 || (data.artists && data.artists.length > 0)) { + results['first-scan'] = Date.now(); + _markSetupComplete('first-scan'); + } + } + } catch (e) { /* skip */ } + } + + // ── /api/watchlist/count — checks if any artist is watched ─────────── + if (!results['watchlist']) { + try { + const resp = await fetch('/api/watchlist/count'); + if (resp.ok) { + const data = await resp.json(); + if (data.count > 0) { + results['watchlist'] = Date.now(); + _markSetupComplete('watchlist'); + } + } + } catch (e) { /* skip */ } + } + + // ── /api/automations — checks if any custom automations exist ──────── + if (!results['automation']) { + try { + const resp = await fetch('/api/automations'); + if (resp.ok) { + const autos = await resp.json(); + // Filter to custom (non-system) automations + const custom = Array.isArray(autos) ? autos.filter(a => !a.is_system) : []; + if (custom.length > 0) { + results['automation'] = Date.now(); + _markSetupComplete('automation'); + } + } + } catch (e) { /* skip */ } + } + + // ── first-download: check dashboard stat card or finished queue ──────── + if (!results['first-download']) { + // Dashboard stat card shows "X Completed this session" + const finishedCard = document.querySelector('#finished-downloads-card .stat-card-value'); + const finishedVal = finishedCard ? parseInt(finishedCard.textContent) : 0; + if (finishedVal > 0) { + results['first-download'] = Date.now(); + _markSetupComplete('first-download'); + } + // Also check the finished queue (if on downloads page) + if (!results['first-download']) { + const fq = document.querySelector('#finished-queue'); + if (fq && fq.querySelector('.download-item')) { + results['first-download'] = Date.now(); + _markSetupComplete('first-download'); + } + } + } + + return results; +} + +async function openSetupPanel() { + closeSetupPanel(); + + // Show loading state immediately + const loader = document.createElement('div'); + loader.className = 'helper-setup-panel visible'; + loader.innerHTML = ` +
+
+

Setup Progress

+ +
+
+
+
+ Checking your setup... +
+ `; + document.body.appendChild(loader); + _setupPanel = loader; + + const status = await _checkSetupStatus(); + + // Replace loader with real panel + if (_setupPanel) _setupPanel.remove(); + const completedCount = SETUP_STEPS.filter(s => status[s.id]).length; + const totalCount = SETUP_STEPS.length; + const pct = Math.round((completedCount / totalCount) * 100); + + const panel = document.createElement('div'); + panel.className = 'helper-setup-panel'; + panel.innerHTML = ` +
+
+

Setup Progress

+ +
+
+
+ + + + + ${pct}% +
+
+ ${completedCount} of ${totalCount} + steps complete +
+
+
+
+ ${SETUP_STEPS.map(step => { + const done = !!status[step.id]; + return ` +
+
${done ? '✓' : step.icon}
+
+
${step.label}
+
${step.desc}
+
+ ${!done ? `` : ''} +
`; + }).join('')} +
+ ${pct === 100 ? '
All set! SoulSync is fully configured. 🎉
' : ''} + `; + + document.body.appendChild(panel); + _setupPanel = panel; + requestAnimationFrame(() => panel.classList.add('visible')); +} + +function setupGoTo(stepId) { + const step = SETUP_STEPS.find(s => s.id === stepId); + if (!step) return; + exitHelperMode(); + navigateToPage(step.page); + if (step.settingsTab) { + setTimeout(() => typeof switchSettingsTab === 'function' && switchSettingsTab(step.settingsTab), 400); + } + if (step.selector) { + setTimeout(() => { + const el = document.querySelector(step.selector); + if (el) el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 500); + } +} + +function closeSetupPanel() { + if (_setupPanel) { _setupPanel.remove(); _setupPanel = null; } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// KEYBOARD SHORTCUT OVERLAY (Phase 4) +// ═══════════════════════════════════════════════════════════════════════════ + +const KEYBOARD_SHORTCUTS = [ + // Global + { key: '?', desc: 'Open helper menu', scope: 'Global' }, + { key: 'Ctrl+K', desc: 'Search help topics', scope: 'Global' }, + { key: 'Esc', desc: 'Close modal / Exit helper', scope: 'Global' }, + + // Player + { key: 'Space', desc: 'Play / Pause', scope: 'Player' }, + { key: '←', desc: 'Skip back 5 seconds', scope: 'Player' }, + { key: '→', desc: 'Skip forward 5 seconds', scope: 'Player' }, + { key: '↑', desc: 'Volume up 5%', scope: 'Player' }, + { key: '↓', desc: 'Volume down 5%', scope: 'Player' }, + { key: 'M', desc: 'Mute / Unmute', scope: 'Player' }, + + // Helper + { key: '←/→', desc: 'Navigate tour steps', scope: 'Helper Tours' }, + + // Forms + { key: 'Enter', desc: 'Submit / Confirm / Search', scope: 'Forms & Search' }, + { key: 'Esc', desc: 'Cancel edit / Close search', scope: 'Forms & Search' }, +]; + +let _shortcutsCloseHandler = null; + +function openShortcutsOverlay() { + closeShortcutsOverlay(); + + // Group by scope + const groups = {}; + KEYBOARD_SHORTCUTS.forEach(s => { + if (!groups[s.scope]) groups[s.scope] = []; + groups[s.scope].push(s); + }); + + const overlay = document.createElement('div'); + overlay.className = 'helper-shortcuts-overlay'; + overlay.innerHTML = ` +
+
+

Keyboard Shortcuts

+ Press any key to dismiss +
+
+ ${Object.entries(groups).map(([scope, shortcuts]) => ` +
+
${scope}
+ ${shortcuts.map(s => ` +
+ ${s.key} + ${s.desc} +
+ `).join('')} +
+ `).join('')} +
+
+ `; + + overlay.addEventListener('click', (e) => { + if (e.target === overlay) exitHelperMode(); + }); + document.body.appendChild(overlay); + _shortcutsOverlay = overlay; + requestAnimationFrame(() => overlay.classList.add('visible')); + + // Dismiss on any keypress (except the initial ?) + _shortcutsCloseHandler = (e) => { + if (e.key === '?') return; // ignore the key that opened us + exitHelperMode(); + }; + setTimeout(() => document.addEventListener('keydown', _shortcutsCloseHandler), 200); +} + +function closeShortcutsOverlay() { + if (_shortcutsCloseHandler) { + document.removeEventListener('keydown', _shortcutsCloseHandler); + _shortcutsCloseHandler = null; + } + if (_shortcutsOverlay) { _shortcutsOverlay.remove(); _shortcutsOverlay = null; } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// SEARCH WITHIN HELPER (Phase 5) +// ═══════════════════════════════════════════════════════════════════════════ + +function openHelperSearch() { + closeHelperSearch(); + + const panel = document.createElement('div'); + panel.className = 'helper-search-panel'; + panel.innerHTML = ` +
+
+ 🔍 + +
+ +
+
+
Type to search 200+ help topics, tours, and shortcuts...
+
+ `; + + document.body.appendChild(panel); + _helperSearchPanel = panel; + + const input = panel.querySelector('.helper-search-input'); + const resultsContainer = panel.querySelector('.helper-search-results'); + + input.addEventListener('input', () => { + const q = input.value.trim().toLowerCase(); + if (q.length < 2) { + resultsContainer.innerHTML = '
Type to search 200+ help topics, tours, and shortcuts...
'; + return; + } + + const matches = []; + + // Search HELPER_CONTENT + for (const [selector, content] of Object.entries(HELPER_CONTENT)) { + const haystack = (content.title + ' ' + content.description + ' ' + (content.tips || []).join(' ')).toLowerCase(); + const idx = haystack.indexOf(q); + if (idx !== -1) { + matches.push({ type: 'content', selector, title: content.title, desc: content.description, score: idx }); + } + } + + // Search HELPER_TOURS + for (const [id, tour] of Object.entries(HELPER_TOURS)) { + const haystack = (tour.title + ' ' + tour.description).toLowerCase(); + const idx = haystack.indexOf(q); + if (idx !== -1) { + matches.push({ type: 'tour', tourId: id, title: tour.icon + ' ' + tour.title, desc: tour.description + ` (${tour.steps.length} steps)`, score: idx }); + } + } + + // Search KEYBOARD_SHORTCUTS + for (const shortcut of KEYBOARD_SHORTCUTS) { + const haystack = (shortcut.key + ' ' + shortcut.desc + ' ' + shortcut.scope).toLowerCase(); + const idx = haystack.indexOf(q); + if (idx !== -1) { + matches.push({ type: 'shortcut', title: shortcut.key + ' — ' + shortcut.desc, desc: 'Scope: ' + shortcut.scope, score: idx + 100 }); + } + } + + // Sort: title matches first, then by position + matches.sort((a, b) => a.score - b.score); + + if (matches.length === 0) { + resultsContainer.innerHTML = '
No results found for "' + q.replace(/'; + return; + } + + resultsContainer.innerHTML = matches.slice(0, 20).map((m, i) => { + const typeIcon = m.type === 'tour' ? '🚶' : m.type === 'shortcut' ? '⌨️' : '🎯'; + const typeLabel = m.type === 'tour' ? 'Tour' : m.type === 'shortcut' ? 'Shortcut' : 'Help'; + return ` + `; + }).join(''); + + // Bind click handlers + const displayedMatches = matches.slice(0, 20); + resultsContainer.querySelectorAll('.helper-search-result').forEach((btn, i) => { + btn.addEventListener('click', () => _handleSearchResultClick(displayedMatches[i])); + }); + }); + + // Position near float button + const floatBtn = document.getElementById('helper-float-btn'); + if (floatBtn) { + const btnRect = floatBtn.getBoundingClientRect(); + panel.style.right = (window.innerWidth - btnRect.right) + 'px'; + panel.style.bottom = (window.innerHeight - btnRect.top + 8) + 'px'; + } + + requestAnimationFrame(() => { + panel.classList.add('visible'); + input.focus(); + }); +} + +function _highlightMatch(text, query) { + const idx = text.toLowerCase().indexOf(query.toLowerCase()); + if (idx === -1) return text; + return text.slice(0, idx) + '' + text.slice(idx, idx + query.length) + '' + text.slice(idx + query.length); +} + +function _handleSearchResultClick(match) { + if (match.type === 'tour') { + exitHelperMode(); + setTimeout(() => { + HelperState.mode = 'tour'; + const floatBtn = document.getElementById('helper-float-btn'); + if (floatBtn) floatBtn.classList.add('active'); + startTour(match.tourId); + }, 100); + } else if (match.type === 'content') { + exitHelperMode(); + + // Try to find the element on the current page first + let el = document.querySelector(match.selector); + if (el && el.offsetParent !== null) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setTimeout(() => showHelperPopover(el, HELPER_CONTENT[match.selector]), 300); + return; + } + + // Element not visible — try to detect which page it's on from the selector + const pageHint = _guessPageFromSelector(match.selector); + if (pageHint) { + navigateToPage(pageHint); + setTimeout(() => { + const el2 = document.querySelector(match.selector); + if (el2) { + el2.scrollIntoView({ behavior: 'smooth', block: 'center' }); + setTimeout(() => showHelperPopover(el2, HELPER_CONTENT[match.selector]), 300); + } + }, 400); + } + } else if (match.type === 'shortcut') { + exitHelperMode(); + setTimeout(() => activateHelperMode('shortcuts'), 100); + } +} + +function _guessPageFromSelector(selector) { + // Map well-known selector prefixes/patterns to pages + const pageHints = { + 'sync': ['sync-tab', 'sync-header', 'sync-sidebar', 'playlist-header', 'spotify-refresh', 'tidal-refresh', 'deezer-url', 'youtube-url', 'spotify-public', 'import-file-icon', 'mirrored'], + 'downloads': ['enh-', 'enhanced-search', 'search-mode', 'download-manager', 'toggle-download-manager'], + 'discover': ['discover-', 'spotify-library', 'recent-releases', 'seasonal', 'release-radar', 'discovery-weekly', 'build-playlist', 'listenbrainz', 'decade-tabs', 'genre-tabs', 'daily-mixes', 'personalized-'], + 'artists': ['artists-search', 'artists-hero', 'artist-detail', 'similar-artists'], + 'automations': ['automations-', 'auto-', 'builder-'], + 'library': ['library-', 'alphabet-selector', 'watchlist-filter'], + 'stats': ['stats-'], + 'import': ['import-page-'], + 'settings': ['settings-', 'stg-tab', 'api-service', 'server-toggle', 'save-button', 'spotify-client', 'soulseek-url', 'quality-profile'], + 'issues': ['issues-'], + 'dashboard': ['dashboard-', 'service-card', 'watchlist-button', 'wishlist-button', 'db-updater', 'metadata-updater', 'quality-scanner', 'duplicate-cleaner', 'discovery-pool-card', 'retag-tool', 'media-scan', 'backup-manager', 'metadata-cache'], + }; + + const selectorLower = selector.toLowerCase(); + for (const [page, patterns] of Object.entries(pageHints)) { + for (const pattern of patterns) { + if (selectorLower.includes(pattern.toLowerCase())) { + return page; + } + } + } + return null; +} + +function closeHelperSearch() { + if (_helperSearchPanel) { _helperSearchPanel.remove(); _helperSearchPanel = null; } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// WHAT'S NEW (Phase 6) +// ═══════════════════════════════════════════════════════════════════════════ + +const WHATS_NEW = { + '2.2': [ + { title: 'Interactive Help System', desc: 'Full contextual help with guided tours, search, shortcuts, setup tracking, and troubleshooting', selector: '#helper-float-btn' }, + { title: 'Genre Explorer', desc: 'Browse music by genre across all metadata sources (Spotify, iTunes, Deezer)', page: 'discover', selector: '#genre-tabs' }, + { title: 'In Library Badges', desc: 'Search results now show "In Library" badges for albums and tracks you already own', page: 'downloads', selector: '.enhanced-search-input-wrapper' }, + { title: 'Rich Artist Profiles', desc: 'Full-bleed hero section with bio, stats, genres, and service links', page: 'artists', selector: '#artists-search-input' }, + { title: 'FLAC Bit Depth Control', desc: 'Quality profiles now enforce 16-bit vs 24-bit preference with fallback' }, + { title: 'Multi-Source Search Tabs', desc: 'View results from Spotify, iTunes, and Deezer side by side', page: 'downloads', selector: '.search-mode-toggle' }, + { title: 'Automation Signals', desc: 'Chain automations together using fire/receive signals', page: 'automations', selector: '#auto-section-hub' }, + { title: 'Enhanced Library Manager', desc: 'Inline tag editing, bulk operations, and write-to-file from the library', page: 'library', selector: '.library-controls' }, + { title: 'Deezer Download Source', desc: 'Deezer added as 5th download source with quality fallback' }, + { title: 'Streaming Source Verification', desc: 'Artist/title fuzzy matching prevents wrong track downloads from streaming sources' }, + ], + '2.1': [ + { title: 'Personalized Discovery', desc: 'Daily Mixes, Hidden Gems, Forgotten Favorites, and more', page: 'discover' }, + { title: 'ListenBrainz Integration', desc: 'Algorithmic playlists from your listening history', page: 'discover' }, + { title: 'Build a Playlist', desc: 'Custom playlist generator from seed artists', page: 'discover' }, + { title: 'Time Machine', desc: 'Browse music by decade from your library', page: 'discover' }, + { title: 'Listening Stats', desc: 'Charts, rankings, and library health metrics from your media server', page: 'stats' }, + ], +}; + +function _getCurrentVersion() { + const btn = document.querySelector('.version-button'); + return btn ? btn.textContent.trim().replace('v', '') : '2.1'; +} + +function _getLatestWhatsNewVersion() { + const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + return versions[0] || '2.1'; +} + +function openWhatsNew() { + dismissHelperPopover(); + const latestVersion = _getLatestWhatsNewVersion(); + const notes = WHATS_NEW[latestVersion]; + + // Mark as seen + localStorage.setItem('soulsync_helper_version_seen', latestVersion); + _updateHelperBadge(); + + if (!notes || !notes.length) { + // Fall back to existing version modal + exitHelperMode(); + const versionBtn = document.querySelector('.version-button'); + if (versionBtn) versionBtn.click(); + return; + } + + const panel = document.createElement('div'); + panel.className = 'helper-popover helper-whats-new-panel'; + panel.innerHTML = ` +
+
What's New in v${latestVersion}
+ +
+
+ ${notes.map(h => { + const hasTarget = !!(h.selector || h.page); + const linkText = h.selector ? 'Show me →' : h.page ? 'Go to page →' : ''; + return ` +
+
${h.title}
+
${h.desc}
+ ${linkText ? `${linkText}` : ''} +
`; + }).join('')} +
+ + `; + + // "Show me" click handlers + panel.querySelectorAll('.helper-whats-new-item.clickable').forEach(item => { + item.addEventListener('click', () => { + const page = item.getAttribute('data-page'); + const sel = item.getAttribute('data-selector'); + exitHelperMode(); + if (page) navigateToPage(page); + if (sel) { + setTimeout(() => { + const el = document.querySelector(sel); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('helper-highlight'); + setTimeout(() => el.classList.remove('helper-highlight'), 3000); + } + }, page ? 400 : 50); + } + }); + }); + + document.body.appendChild(panel); + _helperPopover = panel; + + const floatBtn = document.getElementById('helper-float-btn'); + if (floatBtn) { + const btnRect = floatBtn.getBoundingClientRect(); + panel.style.right = (window.innerWidth - btnRect.right) + 'px'; + panel.style.bottom = (window.innerHeight - btnRect.top + 8) + 'px'; + panel.style.left = 'auto'; + panel.style.top = 'auto'; + } + requestAnimationFrame(() => panel.classList.add('visible')); +} + +function _openFullChangelog() { + exitHelperMode(); + const versionBtn = document.querySelector('.version-button'); + if (versionBtn) versionBtn.click(); +} + +function _showOlderNotes() { + // Cycle to next older version in the what's new panel + const versions = Object.keys(WHATS_NEW).sort((a, b) => parseFloat(b) - parseFloat(a)); + const panel = _helperPopover; + if (!panel) return; + const currentTitle = panel.querySelector('.helper-popover-title'); + const currentVer = currentTitle?.textContent.match(/v([\d.]+)/)?.[1] || versions[0]; + const currentIdx = versions.indexOf(currentVer); + const nextIdx = (currentIdx + 1) % versions.length; + const nextVer = versions[nextIdx]; + + // Rebuild the list content + const notes = WHATS_NEW[nextVer]; + if (currentTitle) currentTitle.textContent = `What's New in v${nextVer}`; + const listEl = panel.querySelector('.helper-whats-new-list'); + if (listEl && notes) { + listEl.innerHTML = notes.map(h => { + const hasTarget = !!(h.selector || h.page); + const linkText = h.selector ? 'Show me →' : h.page ? 'Go to page →' : ''; + return ` +
+
${h.title}
+
${h.desc}
+ ${linkText ? `${linkText}` : ''} +
`; + }).join(''); + + // Rebind click handlers + listEl.querySelectorAll('.helper-whats-new-item.clickable').forEach(item => { + item.addEventListener('click', () => { + const page = item.getAttribute('data-page'); + const sel = item.getAttribute('data-selector'); + exitHelperMode(); + if (page) navigateToPage(page); + if (sel) { + setTimeout(() => { + const el = document.querySelector(sel); + if (el) { + el.scrollIntoView({ behavior: 'smooth', block: 'center' }); + el.classList.add('helper-highlight'); + setTimeout(() => el.classList.remove('helper-highlight'), 3000); + } + }, page ? 400 : 50); + } + }); + }); + } +} + +function _updateHelperBadge() { + const floatBtn = document.getElementById('helper-float-btn'); + if (!floatBtn) return; + const seen = localStorage.getItem('soulsync_helper_version_seen'); + const latest = _getLatestWhatsNewVersion(); + if (seen !== latest) { + floatBtn.classList.add('has-badge'); + } else { + floatBtn.classList.remove('has-badge'); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// TROUBLESHOOT MODE (Phase 7) +// ═══════════════════════════════════════════════════════════════════════════ + +const TROUBLESHOOT_RULES = [ + { + selector: '#spotify-service-card .status-dot.disconnected, #spotify-service-card .status-dot.error', + title: 'Metadata Source Disconnected', + steps: [ + 'Go to Settings → Connections and verify your API credentials', + 'Click "Authenticate" to re-connect to Spotify', + 'If rate limited, wait for the countdown timer to expire', + 'Try switching to iTunes (no authentication required) as a fallback' + ], + action: { label: 'Open Settings', fn: () => navigateToPage('settings') } + }, + { + selector: '#media-server-service-card .status-dot.disconnected, #media-server-service-card .status-dot.error', + title: 'Media Server Disconnected', + steps: [ + 'Check that your media server (Plex/Jellyfin/Navidrome) is running', + 'Verify the server URL and API token in Settings → Connections', + 'Ensure the server is accessible from the SoulSync host machine', + 'Try clicking "Test Connection" on the service card' + ], + action: { label: 'Open Settings', fn: () => navigateToPage('settings') } + }, + { + selector: '#soulseek-service-card .status-dot.disconnected, #soulseek-service-card .status-dot.error', + title: 'Download Source Disconnected', + steps: [ + 'Verify your Soulseek/download client is running and reachable', + 'Check the API URL and credentials in Settings → Downloads', + 'For streaming sources (Tidal, Qobuz), verify your subscription is active', + 'Try restarting the download client application' + ], + action: { label: 'Configure Downloads', fn: () => { navigateToPage('settings'); setTimeout(() => typeof switchSettingsTab === 'function' && switchSettingsTab('downloads'), 400); } } + }, + { + selector: '.spotify-rate-limit-modal:not(.hidden), .rate-limit-banner', + title: 'Spotify Rate Limited', + steps: [ + 'Spotify has temporarily blocked API requests due to too many calls', + 'Wait for the countdown timer to expire — requests auto-resume', + 'Avoid running multiple bulk operations (enrichment + search) simultaneously', + 'Consider switching to iTunes temporarily to continue working' + ] + }, + { + selector: '.issue-card.status-open, .issues-stat-open', + title: 'Open Issues in Library', + steps: [ + 'Open issues have been reported for tracks in your library', + 'Go to the Issues page to review and resolve them', + 'Common issues: wrong track downloaded, bad metadata, low audio quality', + 'Each issue has fix suggestions and action buttons' + ], + action: { label: 'View Issues', fn: () => navigateToPage('issues') } + }, +]; + +function activateTroubleshootMode() { + closeTroubleshootMode(); + _troubleshootActive = true; + + // We need to be on the dashboard to scan service cards + const currentPage = document.querySelector('.page.active')?.id?.replace('-page', '') || ''; + if (currentPage !== 'dashboard') { + navigateToPage('dashboard'); + setTimeout(() => _runTroubleshootScan(), 400); + } else { + _runTroubleshootScan(); + } +} + +function _runTroubleshootScan() { + const issues = []; + + TROUBLESHOOT_RULES.forEach(rule => { + const selectors = rule.selector.split(',').map(s => s.trim()); + selectors.forEach(sel => { + try { + const els = document.querySelectorAll(sel); + els.forEach(el => { + if (el.offsetParent !== null || el.offsetWidth > 0) { + issues.push({ el, rule }); + el.classList.add('helper-troubleshoot-target'); + } + }); + } catch (e) { /* invalid selector */ } + }); + }); + + // Deduplicate by rule title + const seen = new Set(); + const uniqueIssues = issues.filter(i => { + if (seen.has(i.rule.title)) return false; + seen.add(i.rule.title); + return true; + }); + + if (uniqueIssues.length === 0) { + // All clear! + const panel = document.createElement('div'); + panel.className = 'helper-popover helper-troubleshoot-panel'; + panel.innerHTML = ` +
+
System Health Check
+ +
+
+
+
All Clear!
+
All services are connected and running normally. No issues detected.
+
+ `; + document.body.appendChild(panel); + _helperPopover = panel; + _positionPanelNearFloatBtn(panel); + return; + } + + // Show issues + const panel = document.createElement('div'); + panel.className = 'helper-popover helper-troubleshoot-panel'; + panel.innerHTML = ` +
+
⚠️ ${uniqueIssues.length} Issue${uniqueIssues.length > 1 ? 's' : ''} Found
+ +
+
+ ${uniqueIssues.map((issue, i) => ` +
+
${issue.rule.title}
+
+ ${issue.rule.steps.map(s => `
• ${s}
`).join('')} +
+ ${issue.rule.action ? `` : ''} +
+ `).join('')} +
+ `; + + // Action click handlers + panel.querySelectorAll('[data-tshoot-idx]').forEach(btn => { + const idx = parseInt(btn.getAttribute('data-tshoot-idx')); + btn.addEventListener('click', () => { + exitHelperMode(); + if (uniqueIssues[idx]?.rule.action?.fn) uniqueIssues[idx].rule.action.fn(); + }); + }); + + document.body.appendChild(panel); + _helperPopover = panel; + _positionPanelNearFloatBtn(panel); +} + +function _positionPanelNearFloatBtn(panel) { + const floatBtn = document.getElementById('helper-float-btn'); + if (floatBtn) { + const btnRect = floatBtn.getBoundingClientRect(); + panel.style.right = (window.innerWidth - btnRect.right) + 'px'; + panel.style.bottom = (window.innerHeight - btnRect.top + 8) + 'px'; + panel.style.left = 'auto'; + panel.style.top = 'auto'; + } + requestAnimationFrame(() => panel.classList.add('visible')); +} + +function closeTroubleshootMode() { + _troubleshootActive = false; + document.querySelectorAll('.helper-troubleshoot-target').forEach(el => el.classList.remove('helper-troubleshoot-target')); +} + +// ═══════════════════════════════════════════════════════════════════════════ +// FIRST-LAUNCH & PAGE-LOAD HOOKS +// ═══════════════════════════════════════════════════════════════════════════ + +document.addEventListener('DOMContentLoaded', () => { + setTimeout(() => { + // First-launch welcome prompt + const hasSetup = localStorage.getItem('soulsync_setup'); + const hasDismissed = localStorage.getItem('soulsync_setup_welcome_dismissed'); + if (!hasSetup && !hasDismissed) { + const floatBtn = document.getElementById('helper-float-btn'); + if (floatBtn) { + floatBtn.classList.add('first-launch-pulse'); + const tip = document.createElement('div'); + tip.className = 'helper-first-launch-tip'; + tip.textContent = 'New here? Click for setup help!'; + tip.addEventListener('click', () => { + tip.remove(); + floatBtn.classList.remove('first-launch-pulse'); + localStorage.setItem('soulsync_setup_welcome_dismissed', '1'); + activateHelperMode('setup'); + }); + document.body.appendChild(tip); + + // Auto-dismiss after 12 seconds + setTimeout(() => { + if (tip.parentElement) { + tip.classList.add('fading'); + setTimeout(() => tip.remove(), 500); + floatBtn.classList.remove('first-launch-pulse'); + } + }, 12000); + } + } + + // What's New badge + _updateHelperBadge(); + }, 2500); +}); diff --git a/webui/static/style.css b/webui/static/style.css index 75ca03a6..dcc94a5f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -1248,49 +1248,341 @@ body { /* (Sidebar helper button removed — using floating button instead) */ /* Floating helper button — always visible, always above everything */ +/* ── Floating Help Button ─────────────────────────────────────────────── */ + .helper-float-btn { position: fixed; bottom: 24px; right: 24px; - width: 42px; - height: 42px; + width: 48px; + height: 48px; border-radius: 50%; - background: rgba(30, 30, 30, 0.85); - border: 1px solid rgba(255, 255, 255, 0.1); - color: rgba(255, 255, 255, 0.5); - font-size: 18px; + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.15) 0%, rgba(30, 30, 30, 0.9) 60%); + border: 1.5px solid rgba(var(--accent-rgb), 0.25); + color: rgba(255, 255, 255, 0.75); + font-size: 20px; font-weight: 900; cursor: pointer; z-index: 999999; display: flex; align-items: center; justify-content: center; - box-shadow: 0 4px 16px rgba(0, 0, 0, 0.4); - transition: all 0.25s ease; - backdrop-filter: blur(12px); + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5), 0 0 0 0 rgba(var(--accent-rgb), 0), inset 0 1px 0 rgba(255, 255, 255, 0.06); + transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); + backdrop-filter: blur(16px); +} + +.helper-float-btn span { + transition: transform 0.3s cubic-bezier(0.4, 0, 0.2, 1), opacity 0.2s ease; + display: block; } .helper-float-btn:hover { transform: scale(1.1); - background: rgba(var(--accent-rgb), 0.2); - border-color: rgba(var(--accent-rgb), 0.4); + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.3) 0%, rgba(30, 30, 30, 0.95) 60%); + border-color: rgba(var(--accent-rgb), 0.5); color: #fff; - box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.3), 0 2px 8px rgba(0, 0, 0, 0.3); + box-shadow: 0 6px 28px rgba(0, 0, 0, 0.5), 0 0 20px rgba(var(--accent-rgb), 0.15), inset 0 1px 0 rgba(255, 255, 255, 0.08); } .helper-float-btn.active { - background: rgb(var(--accent-rgb)); + background: linear-gradient(135deg, rgb(var(--accent-rgb)), rgba(var(--accent-rgb), 0.7)); border-color: rgb(var(--accent-rgb)); color: #fff; - box-shadow: 0 4px 24px rgba(var(--accent-rgb), 0.5), 0 2px 8px rgba(0, 0, 0, 0.3); + box-shadow: 0 6px 32px rgba(var(--accent-rgb), 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); animation: helperFloatPulse 2s ease-in-out infinite; } +.helper-float-btn.menu-open { + transform: scale(1.05); + background: linear-gradient(135deg, rgba(var(--accent-rgb), 0.25) 0%, rgba(40, 40, 40, 0.95) 60%); + border-color: rgba(var(--accent-rgb), 0.4); + color: #fff; + box-shadow: 0 6px 28px rgba(0, 0, 0, 0.5), 0 0 24px rgba(var(--accent-rgb), 0.12); +} + +.helper-float-btn.menu-open span { + transform: rotate(90deg) scale(0.9); + opacity: 0.9; +} + +.helper-float-btn.active span { + transform: scale(1.05); +} + @keyframes helperFloatPulse { 0%, 100% { box-shadow: 0 4px 20px rgba(var(--accent-rgb), 0.4), 0 2px 8px rgba(0, 0, 0, 0.3); } 50% { box-shadow: 0 4px 32px rgba(var(--accent-rgb), 0.6), 0 2px 8px rgba(0, 0, 0, 0.3); } } +/* ── Helper Menu (unified card) ──────────────────────────────────────── */ + +.helper-menu { + position: fixed; + z-index: 999998; + background: rgba(14, 14, 14, 0.97); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 16px; + padding: 6px; + backdrop-filter: blur(24px); + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.65), 0 0 0 1px rgba(255, 255, 255, 0.03), inset 0 1px 0 rgba(255, 255, 255, 0.04); + display: flex; + flex-direction: column; + gap: 2px; + min-width: 220px; + opacity: 0; + transform: translateY(8px) scale(0.96); + transition: opacity 0.2s ease, transform 0.2s cubic-bezier(0.4, 0, 0.2, 1); +} + +.helper-menu.visible { + opacity: 1; + transform: translateY(0) scale(1); +} + +.helper-menu-item { + display: flex; + align-items: center; + gap: 10px; + padding: 9px 14px; + background: transparent; + border: 1px solid transparent; + border-radius: 10px; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + transition: all 0.12s ease; + font-size: 13px; + font-weight: 500; + white-space: nowrap; + box-shadow: none; + animation: helperMenuItemIn 0.2s ease backwards; +} + +@keyframes helperMenuItemIn { + from { opacity: 0; transform: translateX(6px); } + to { opacity: 1; transform: translateX(0); } +} + +.helper-menu-item:hover { + background: rgba(255, 255, 255, 0.06); + color: #fff; +} + +.helper-menu-icon { + font-size: 15px; + width: 22px; + text-align: center; + flex-shrink: 0; +} + +.helper-menu-label { + flex: 1; +} + +/* Contextual tour suggestion (top of menu) */ +.helper-menu-contextual { + background: rgba(var(--accent-rgb), 0.08) !important; + border-color: rgba(var(--accent-rgb), 0.15) !important; + color: #fff !important; + position: relative; + overflow: hidden; +} + +.helper-menu-contextual::before { + content: ''; + position: absolute; + left: 0; + top: 0; + bottom: 0; + width: 3px; + border-radius: 3px 0 0 3px; + background: rgb(var(--accent-rgb)); +} + +.helper-menu-contextual:hover { + background: rgba(var(--accent-rgb), 0.15) !important; + border-color: rgba(var(--accent-rgb), 0.3) !important; +} + +.helper-menu-badge { + font-size: 10px; + font-weight: 700; + color: rgba(var(--accent-rgb), 0.6); + margin-left: auto; + padding-left: 8px; +} + +.helper-menu-divider { + height: 1px; + background: rgba(255, 255, 255, 0.06); + margin: 3px 8px; +} + +/* Tour Overlay (spotlight) */ +.helper-tour-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + z-index: 99998; + animation: tourOverlayIn 0.3s ease; + backdrop-filter: blur(2px); +} + +@keyframes tourOverlayIn { + from { opacity: 0; } + to { opacity: 1; } +} + +.helper-tour-target { + position: relative; + z-index: 99999 !important; + box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.5), 0 0 30px rgba(var(--accent-rgb), 0.15) !important; + border-radius: 10px; + transition: box-shadow 0.3s ease; + animation: tourTargetPulse 2s ease-in-out infinite; +} + +@keyframes tourTargetPulse { + 0%, 100% { box-shadow: 0 0 0 4px rgba(var(--accent-rgb), 0.5), 0 0 30px rgba(var(--accent-rgb), 0.15) !important; } + 50% { box-shadow: 0 0 0 6px rgba(var(--accent-rgb), 0.3), 0 0 40px rgba(var(--accent-rgb), 0.25) !important; } +} + +/* Tour Popover additions */ +.helper-tour-popover { + z-index: 100001 !important; +} + +/* Tour progress bar */ +.helper-tour-progress-bar { + height: 3px; + background: rgba(255, 255, 255, 0.06); + border-radius: 14px 14px 0 0; + overflow: hidden; +} + +.helper-tour-progress-fill { + height: 100%; + background: linear-gradient(90deg, rgb(var(--accent-rgb)), rgb(var(--accent-light-rgb))); + border-radius: 3px; + transition: width 0.4s cubic-bezier(0.4, 0, 0.2, 1); +} + +.helper-tour-step-counter { + font-size: 10px; + font-weight: 700; + color: rgba(var(--accent-rgb), 0.7); + letter-spacing: 0.04em; + padding: 10px 16px 0; +} + +.helper-tour-nav { + display: flex; + justify-content: space-between; + align-items: center; + padding: 8px 16px 14px; + gap: 8px; +} + +.helper-tour-btn { + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.08); + border-radius: 8px; + padding: 6px 14px; + color: rgba(255, 255, 255, 0.6); + font-size: 12px; + font-weight: 600; + cursor: pointer; + transition: all 0.15s ease; +} + +.helper-tour-btn:hover { + background: rgba(255, 255, 255, 0.12); + color: #fff; +} + +.helper-tour-btn-next { + background: rgba(var(--accent-rgb), 0.2); + border-color: rgba(var(--accent-rgb), 0.3); + color: rgb(var(--accent-rgb)); +} + +.helper-tour-btn-next:hover { + background: rgba(var(--accent-rgb), 0.35); + color: #fff; +} + +.helper-tour-btn-skip { + color: rgba(255, 255, 255, 0.35); + border-color: transparent; + background: transparent; +} + +.helper-tour-btn-skip:hover { + color: rgba(255, 255, 255, 0.6); +} + +/* Tour Selector */ +.helper-tour-selector { + max-width: 360px; + width: 360px; +} + +.helper-tour-list { + padding: 8px 12px 12px; + display: flex; + flex-direction: column; + gap: 6px; +} + +.helper-tour-option { + display: flex; + flex-direction: row; + align-items: center; + gap: 12px; + padding: 12px 14px; + background: rgba(255, 255, 255, 0.03); + border: 1px solid rgba(255, 255, 255, 0.06); + border-radius: 10px; + cursor: pointer; + transition: all 0.15s ease; + text-align: left; + width: 100%; +} + +.helper-tour-option-icon { + font-size: 22px; + flex-shrink: 0; + width: 32px; + text-align: center; +} + +.helper-tour-option-body { + flex: 1; + min-width: 0; +} + +.helper-tour-option:hover { + background: rgba(var(--accent-rgb), 0.08); + border-color: rgba(var(--accent-rgb), 0.2); +} + +.helper-tour-option-title { + font-size: 14px; + font-weight: 600; + color: #fff; +} + +.helper-tour-option-desc { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); +} + +.helper-tour-option-steps { + font-size: 11px; + color: rgba(var(--accent-rgb), 0.7); + font-weight: 600; +} + /* Help mode — cursor + hover highlights */ body.helper-mode-active { cursor: help; @@ -1475,6 +1767,706 @@ body.helper-mode-active #dashboard-activity-feed:hover { } } +/* ═══════════════════════════════════════════════════════════════════════════ + HELPER V2 — PHASES 2-7 STYLES + ═══════════════════════════════════════════════════════════════════════════ */ + +/* ── Quick Action Buttons (Phase 3) ──────────────────────────────────────── */ + +.helper-popover-actions { + display: flex; + gap: 6px; + padding: 0 16px 12px; + flex-wrap: wrap; +} + +.helper-action-btn { + padding: 5px 14px; + font-size: 12px; + font-weight: 600; + border-radius: 20px; + border: 1px solid rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.1); + color: rgb(var(--accent-rgb)); + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} + +.helper-action-btn:hover { + background: rgba(var(--accent-rgb), 0.25); + border-color: rgba(var(--accent-rgb), 0.5); + color: #fff; +} + +/* ── Setup Progress Panel (Phase 2) ──────────────────────────────────────── */ + +.helper-setup-panel { + position: fixed; + right: 24px; + bottom: 80px; + width: 380px; + max-height: 80vh; + background: rgba(16, 16, 16, 0.97); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 16px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.04); + backdrop-filter: blur(24px); + z-index: 100000; + opacity: 0; + transform: translateY(12px); + transition: opacity 0.25s ease, transform 0.25s ease; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.helper-setup-panel.visible { + opacity: 1; + transform: translateY(0); +} + +.helper-setup-header { + padding: 18px 20px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.helper-setup-title-row { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 16px; +} + +.helper-setup-title { + margin: 0; + font-size: 16px; + font-weight: 700; + color: #fff; +} + +.helper-setup-ring-row { + display: flex; + align-items: center; + gap: 16px; +} + +.helper-setup-ring { + position: relative; + width: 52px; + height: 52px; + flex-shrink: 0; +} + +.helper-setup-ring-svg { + width: 52px; + height: 52px; + transform: rotate(-90deg); +} + +.helper-setup-ring-progress { + transition: stroke-dasharray 0.6s cubic-bezier(0.4, 0, 0.2, 1); +} + +.helper-setup-ring-text { + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + font-size: 13px; + font-weight: 800; + color: rgb(var(--accent-rgb)); +} + +.helper-setup-count { + display: block; + font-size: 18px; + font-weight: 800; + color: #fff; +} + +.helper-setup-label { + font-size: 12px; + color: rgba(255, 255, 255, 0.4); +} + +.helper-setup-list { + overflow-y: auto; + padding: 8px 12px 12px; + display: flex; + flex-direction: column; + gap: 4px; + flex: 1; +} + +.helper-setup-item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px; + border-radius: 10px; + background: rgba(255, 255, 255, 0.02); + border: 1px solid rgba(255, 255, 255, 0.04); + transition: all 0.15s ease; +} + +.helper-setup-item:hover:not(.done) { + background: rgba(var(--accent-rgb), 0.05); + border-color: rgba(var(--accent-rgb), 0.15); +} + +.helper-setup-item.done { + opacity: 0.5; +} + +.helper-setup-check { + width: 28px; + height: 28px; + display: flex; + align-items: center; + justify-content: center; + font-size: 16px; + flex-shrink: 0; +} + +.helper-setup-item.done .helper-setup-check { + width: 28px; + height: 28px; + border-radius: 50%; + background: rgba(76, 175, 80, 0.2); + color: #4caf50; + font-size: 13px; + font-weight: 700; +} + +.helper-setup-body { + flex: 1; + min-width: 0; +} + +.helper-setup-item-label { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.helper-setup-item-desc { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; +} + +.helper-setup-go { + padding: 4px 12px; + font-size: 11px; + font-weight: 700; + border: 1px solid rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.08); + color: rgb(var(--accent-rgb)); + border-radius: 8px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; + flex-shrink: 0; +} + +.helper-setup-go:hover { + background: rgba(var(--accent-rgb), 0.2); + color: #fff; +} + +.helper-setup-done { + padding: 16px 20px; + text-align: center; + font-size: 14px; + font-weight: 600; + color: #4caf50; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +.helper-setup-loading { + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + padding: 36px 20px; + color: rgba(255, 255, 255, 0.4); + font-size: 13px; +} + +/* ── Keyboard Shortcuts Overlay (Phase 4) ────────────────────────────────── */ + +.helper-shortcuts-overlay { + position: fixed; + inset: 0; + z-index: 100000; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.25s ease; +} + +.helper-shortcuts-overlay.visible { + opacity: 1; +} + +.helper-shortcuts-panel { + background: rgba(16, 16, 16, 0.97); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 18px; + padding: 28px 32px; + min-width: 480px; + max-width: 640px; + box-shadow: 0 24px 80px rgba(0, 0, 0, 0.7); + backdrop-filter: blur(24px); +} + +.helper-shortcuts-header { + display: flex; + justify-content: space-between; + align-items: baseline; + margin-bottom: 24px; +} + +.helper-shortcuts-header h3 { + margin: 0; + font-size: 20px; + font-weight: 800; + color: #fff; + letter-spacing: -0.3px; +} + +.helper-shortcuts-hint { + font-size: 11px; + color: rgba(255, 255, 255, 0.3); +} + +.helper-shortcuts-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 24px; +} + +.helper-shortcuts-scope { + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 0.08em; + color: rgba(var(--accent-rgb), 0.7); + margin-bottom: 10px; +} + +.helper-shortcut-row { + display: flex; + align-items: center; + gap: 12px; + padding: 4px 0; +} + +.helper-kbd { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 32px; + height: 28px; + padding: 0 8px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-bottom-width: 3px; + border-radius: 6px; + font-family: inherit; + font-size: 12px; + font-weight: 700; + color: rgba(255, 255, 255, 0.85); + white-space: nowrap; + box-shadow: 0 1px 0 rgba(255, 255, 255, 0.04); +} + +.helper-shortcut-desc { + font-size: 13px; + color: rgba(255, 255, 255, 0.55); +} + +/* ── Search Panel (Phase 5) ──────────────────────────────────────────────── */ + +.helper-search-panel { + position: fixed; + z-index: 100000; + width: 400px; + max-height: 480px; + background: rgba(16, 16, 16, 0.97); + border: 1px solid rgba(255, 255, 255, 0.1); + border-radius: 14px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.7), 0 0 0 1px rgba(255, 255, 255, 0.04); + backdrop-filter: blur(24px); + display: flex; + flex-direction: column; + opacity: 0; + transform: translateY(8px); + transition: opacity 0.2s ease, transform 0.2s ease; +} + +.helper-search-panel.visible { + opacity: 1; + transform: translateY(0); +} + +.helper-search-header { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 6px 6px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); +} + +.helper-search-input-wrap { + flex: 1; + display: flex; + align-items: center; + gap: 8px; + padding: 0 0 0 14px; +} + +.helper-search-icon { + font-size: 14px; + flex-shrink: 0; +} + +.helper-search-input { + flex: 1; + background: transparent; + border: none; + outline: none; + color: #fff; + font-size: 14px; + padding: 10px 0; + font-family: inherit; +} + +.helper-search-input::placeholder { + color: rgba(255, 255, 255, 0.3); +} + +.helper-search-results { + overflow-y: auto; + padding: 6px; + flex: 1; + min-height: 0; +} + +.helper-search-hint { + padding: 20px 16px; + text-align: center; + font-size: 12px; + color: rgba(255, 255, 255, 0.3); +} + +.helper-search-result { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: 8px; + border: none; + background: transparent; + cursor: pointer; + transition: all 0.12s ease; + width: 100%; + text-align: left; +} + +.helper-search-result:hover { + background: rgba(var(--accent-rgb), 0.08); +} + +.helper-search-result-type { + font-size: 14px; + flex-shrink: 0; + margin-top: 1px; +} + +.helper-search-result-body { + flex: 1; + min-width: 0; +} + +.helper-search-result-title { + font-size: 13px; + font-weight: 600; + color: #fff; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.helper-search-result-title mark { + background: rgba(var(--accent-rgb), 0.3); + color: rgb(var(--accent-light-rgb)); + border-radius: 2px; + padding: 0 1px; +} + +.helper-search-result-desc { + font-size: 11px; + color: rgba(255, 255, 255, 0.4); + margin-top: 2px; + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} + +/* ── What's New Panel (Phase 6) ──────────────────────────────────────────── */ + +.helper-whats-new-panel { + width: 380px; + max-width: 380px; + max-height: 70vh; + overflow-y: auto; +} + +.helper-whats-new-list { + padding: 4px 12px 8px; + display: flex; + flex-direction: column; + gap: 4px; +} + +.helper-whats-new-item { + padding: 10px 14px; + border-radius: 8px; + border: 1px solid rgba(255, 255, 255, 0.04); + transition: all 0.15s ease; +} + +.helper-whats-new-item.clickable { + cursor: pointer; +} + +.helper-whats-new-item.clickable:hover { + background: rgba(var(--accent-rgb), 0.06); + border-color: rgba(var(--accent-rgb), 0.15); +} + +.helper-whats-new-title { + font-size: 13px; + font-weight: 600; + color: #fff; +} + +.helper-whats-new-desc { + font-size: 12px; + color: rgba(255, 255, 255, 0.45); + margin-top: 3px; + line-height: 1.4; +} + +.helper-whats-new-show { + display: inline-block; + margin-top: 4px; + font-size: 11px; + font-weight: 600; + color: rgba(var(--accent-rgb), 0.7); +} + +.helper-whats-new-item.clickable:hover .helper-whats-new-show { + color: rgb(var(--accent-rgb)); +} + +.helper-whats-new-footer { + display: flex; + gap: 8px; + padding: 8px 16px 14px; + border-top: 1px solid rgba(255, 255, 255, 0.06); +} + +/* ── What's New Badge ───────────────────────────────────────────────────── */ + +.helper-float-btn.has-badge::after { + content: ''; + position: absolute; + top: 4px; + right: 4px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #ff5252; + border: 2px solid rgba(16, 16, 16, 0.95); + animation: helperBadgePulse 2s ease-in-out infinite; +} + +@keyframes helperBadgePulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.2); } +} + +/* ── Troubleshoot Mode (Phase 7) ─────────────────────────────────────────── */ + +.helper-troubleshoot-target { + animation: troubleshootPulse 1.5s ease-in-out infinite; + outline: 2px solid rgba(255, 82, 82, 0.6) !important; + outline-offset: 3px; + position: relative; + z-index: 10; +} + +@keyframes troubleshootPulse { + 0%, 100% { outline-color: rgba(255, 82, 82, 0.6); } + 50% { outline-color: rgba(255, 82, 82, 0.2); } +} + +.helper-troubleshoot-panel { + width: 400px; + max-width: 400px; + max-height: 70vh; + overflow-y: auto; +} + +.helper-troubleshoot-clear { + padding: 24px 20px 28px; + text-align: center; +} + +.helper-troubleshoot-clear-icon { + font-size: 36px; + margin-bottom: 10px; +} + +.helper-troubleshoot-clear-text { + font-size: 18px; + font-weight: 800; + color: #4caf50; + margin-bottom: 6px; +} + +.helper-troubleshoot-clear-desc { + font-size: 13px; + color: rgba(255, 255, 255, 0.45); + line-height: 1.5; +} + +.helper-troubleshoot-list { + padding: 4px 12px 12px; + display: flex; + flex-direction: column; + gap: 8px; +} + +.helper-troubleshoot-issue { + padding: 14px; + border-radius: 10px; + background: rgba(255, 82, 82, 0.04); + border: 1px solid rgba(255, 82, 82, 0.12); +} + +.helper-troubleshoot-issue-title { + font-size: 14px; + font-weight: 700; + color: #ff8a80; + margin-bottom: 8px; +} + +.helper-troubleshoot-steps { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 10px; +} + +.helper-troubleshoot-step { + font-size: 12px; + color: rgba(255, 255, 255, 0.5); + line-height: 1.5; +} + +/* ── First-Launch Welcome (Setup) ────────────────────────────────────────── */ + +.helper-float-btn.first-launch-pulse { + animation: firstLaunchPulse 1.5s ease-in-out infinite; +} + +@keyframes firstLaunchPulse { + 0%, 100% { box-shadow: 0 4px 16px rgba(var(--accent-rgb), 0.3), 0 0 0 0 rgba(var(--accent-rgb), 0.4); } + 50% { box-shadow: 0 4px 24px rgba(var(--accent-rgb), 0.5), 0 0 0 8px rgba(var(--accent-rgb), 0); } +} + +.helper-first-launch-tip { + position: fixed; + bottom: 34px; + right: 84px; + padding: 8px 16px; + background: rgba(16, 16, 16, 0.95); + border: 1px solid rgba(var(--accent-rgb), 0.3); + border-radius: 10px; + color: rgba(255, 255, 255, 0.8); + font-size: 13px; + font-weight: 600; + cursor: pointer; + z-index: 999999; + backdrop-filter: blur(12px); + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.5); + animation: firstLaunchTipIn 0.4s ease backwards 0.5s; + transition: opacity 0.5s ease; + white-space: nowrap; +} + +.helper-first-launch-tip:hover { + background: rgba(var(--accent-rgb), 0.15); + color: #fff; +} + +.helper-first-launch-tip.fading { + opacity: 0; +} + +@keyframes firstLaunchTipIn { + from { opacity: 0; transform: translateX(8px); } + to { opacity: 1; transform: translateX(0); } +} + +/* ── Mobile overrides for helper V2 ──────────────────────────────────────── */ + +@media (max-width: 768px) { + .helper-setup-panel { + right: 8px; + left: 8px; + bottom: 72px; + width: auto; + } + + .helper-search-panel { + right: 8px !important; + left: 8px; + bottom: 72px !important; + width: auto; + } + + .helper-shortcuts-panel { + min-width: unset; + max-width: calc(100vw - 32px); + padding: 20px; + } + + .helper-shortcuts-grid { + grid-template-columns: 1fr; + } + + .helper-whats-new-panel, + .helper-troubleshoot-panel { + width: auto; + max-width: calc(100vw - 24px); + } + + .helper-first-launch-tip { + right: 12px; + bottom: 72px; + } +} + .version-button.update-available { color: rgb(var(--accent-light-rgb)); border-color: rgba(var(--accent-rgb), 0.4);