From 7e42e20887f51ad5639153cc14d17df42037cb31 Mon Sep 17 00:00:00 2001 From: Broque Thomas Date: Sat, 27 Sep 2025 20:16:18 -0700 Subject: [PATCH] beatport progress --- beatport_unified_scraper.py | 210 ++++++++++- web_server.py | 269 ++++++++++++++ webui/index.html | 122 +++++++ webui/static/script.js | 630 ++++++++++++++++++++++++++++++++- webui/static/style.css | 681 ++++++++++++++++++++++++++++++++++++ 5 files changed, 1906 insertions(+), 6 deletions(-) diff --git a/beatport_unified_scraper.py b/beatport_unified_scraper.py index 6e9089cf..374a6d81 100644 --- a/beatport_unified_scraper.py +++ b/beatport_unified_scraper.py @@ -831,7 +831,7 @@ class BeatportUnifiedScraper: return tracks def scrape_genre_charts(self, genre: Dict, limit: int = 100) -> List[Dict]: - """Scrape charts for a specific genre""" + """Scrape charts for a specific genre (default: top tracks)""" genre_url = f"{self.base_url}/genre/{genre['slug']}/{genre['id']}" soup = self.get_page(genre_url) @@ -839,6 +839,214 @@ class BeatportUnifiedScraper: return tracks + def scrape_genre_top_10(self, genre: Dict) -> List[Dict]: + """Scrape top 10 tracks for a specific genre""" + return self.scrape_genre_charts(genre, limit=10) + + def scrape_genre_releases(self, genre: Dict, limit: int = 100) -> List[Dict]: + """Scrape top releases for a specific genre""" + genre_url = f"{self.base_url}/genre/{genre['slug']}/{genre['id']}" + + soup = self.get_page(genre_url) + if not soup: + return [] + + # Try to find releases section on genre page + releases = self.extract_releases_from_page(soup, f"{genre['name']} Top Releases", limit) + + # If no releases found with release extraction, try track extraction + if not releases: + print(f" ⚠️ No releases found with release method, trying track method for {genre['name']}") + releases = self.extract_tracks_from_page(soup, f"{genre['name']} Top Releases", limit) + # Mark these as releases + for release in releases: + release['type'] = 'release' + + return releases + + def scrape_genre_staff_picks(self, genre: Dict, limit: int = 50) -> List[Dict]: + """Scrape staff picks for a specific genre""" + genre_url = f"{self.base_url}/genre/{genre['slug']}/{genre['id']}" + + soup = self.get_page(genre_url) + if not soup: + return [] + + tracks = [] + + # Look for staff picks, editorial, or featured sections on genre page + staff_sections = [ + 'staff pick', 'editorial', 'featured', 'editor', 'hype pick', + 'weekend pick', 'best new', 'exclusives' + ] + + for section_name in staff_sections: + # Find section headings that match staff pick patterns + section_heading = soup.find(['h1', 'h2', 'h3', 'h4'], + string=re.compile(rf'{section_name}', re.I)) + + if section_heading: + print(f" 📝 Found staff picks section: {section_heading.get_text(strip=True)}") + section_container = section_heading.find_parent() + if section_container: + content_area = section_container.find_next_sibling() + if content_area: + section_tracks = self.extract_tracks_from_page( + content_area, f"{genre['name']} Staff Picks", limit + ) + if section_tracks: + tracks.extend(section_tracks) + break # Found staff picks, no need to continue + + # If no specific staff picks section found, try to find any editorial content + if not tracks: + print(f" 🔍 No specific staff picks section found, looking for editorial content...") + # Look for DJ charts or featured charts on the genre page + chart_links = soup.find_all('a', href=re.compile(r'/chart/')) + for chart_link in chart_links[:10]: # Limit to first 10 charts + chart_name = chart_link.get_text(strip=True) + if chart_name and len(chart_name) > 3: + track_info = { + 'position': len(tracks) + 1, + 'artist': 'Various Artists', + 'title': chart_name, + 'list_name': f"{genre['name']} Staff Picks", + 'url': urljoin(self.base_url, chart_link.get('href', '')), + 'chart_type': 'staff_pick' + } + tracks.append(track_info) + if len(tracks) >= limit: + break + + return tracks + + def scrape_genre_latest_releases(self, genre: Dict, limit: int = 50) -> List[Dict]: + """Scrape latest releases for a specific genre""" + genre_url = f"{self.base_url}/genre/{genre['slug']}/{genre['id']}" + + soup = self.get_page(genre_url) + if not soup: + return [] + + # Look for latest releases, new releases, or recent sections + latest_sections = ['latest', 'new releases', 'recent', 'newest'] + tracks = [] + + for section_name in latest_sections: + section_heading = soup.find(['h1', 'h2', 'h3', 'h4'], + string=re.compile(rf'{section_name}', re.I)) + + if section_heading: + print(f" 🕒 Found latest releases section: {section_heading.get_text(strip=True)}") + section_container = section_heading.find_parent() + if section_container: + content_area = section_container.find_next_sibling() + if content_area: + section_tracks = self.extract_tracks_from_page( + content_area, f"Latest {genre['name']} Releases", limit + ) + if section_tracks: + tracks.extend(section_tracks) + break + + # If no specific latest section found, try releases extraction + if not tracks: + print(f" 🔍 No specific latest releases section found, trying general releases...") + tracks = self.scrape_genre_releases(genre, limit) + + return tracks + + def scrape_genre_new_charts(self, genre: Dict, limit: int = 50) -> List[Dict]: + """Scrape new charts (DJ/artist curated) for a specific genre""" + genre_url = f"{self.base_url}/genre/{genre['slug']}/{genre['id']}" + + soup = self.get_page(genre_url) + if not soup: + return [] + + tracks = [] + + # Look for DJ charts, artist charts, or curated content on genre page + chart_links = soup.find_all('a', href=re.compile(r'/chart/')) + + for chart_link in chart_links[:limit]: + chart_name = chart_link.get_text(strip=True) + chart_href = chart_link.get('href', '') + + if chart_name and chart_href and len(chart_name) > 3: + # Extract additional info if available (artist name, etc.) + chart_container = chart_link.find_parent() + artist_name = "Various Artists" + + # Try to find artist info near the chart + if chart_container: + # Look for artist links in the same container + artist_link = chart_container.find('a', href=re.compile(r'/artist/')) + if artist_link: + artist_name = artist_link.get_text(strip=True) + + chart_info = { + 'position': len(tracks) + 1, + 'artist': artist_name, + 'title': chart_name, + 'list_name': f"New {genre['name']} Charts", + 'url': urljoin(self.base_url, chart_href), + 'chart_type': 'new_chart' + } + tracks.append(chart_info) + + return tracks + + def discover_genre_page_sections(self, genre: Dict) -> Dict: + """Analyze a genre page to discover all available sections""" + genre_url = f"{self.base_url}/genre/{genre['slug']}/{genre['id']}" + + print(f"🔍 Discovering sections for {genre['name']} genre page...") + + soup = self.get_page(genre_url) + if not soup: + return {} + + sections = { + 'top_tracks': [], + 'top_releases': [], + 'staff_picks': [], + 'latest_releases': [], + 'new_charts': [], + 'other_sections': [] + } + + # Find all section headings + headings = soup.find_all(['h1', 'h2', 'h3', 'h4']) + + for heading in headings: + text = heading.get_text(strip=True).lower() + + if any(keyword in text for keyword in ['top 100', 'top 10', 'chart']): + sections['top_tracks'].append(heading.get_text(strip=True)) + elif any(keyword in text for keyword in ['release', 'album', 'ep']): + sections['top_releases'].append(heading.get_text(strip=True)) + elif any(keyword in text for keyword in ['staff', 'editor', 'pick', 'featured']): + sections['staff_picks'].append(heading.get_text(strip=True)) + elif any(keyword in text for keyword in ['latest', 'new', 'recent']): + sections['latest_releases'].append(heading.get_text(strip=True)) + elif 'chart' in text: + sections['new_charts'].append(heading.get_text(strip=True)) + else: + sections['other_sections'].append(heading.get_text(strip=True)) + + # Count DJ/artist charts + chart_links = soup.find_all('a', href=re.compile(r'/chart/')) + sections['chart_count'] = len(chart_links) + + print(f"✅ Discovered sections for {genre['name']}:") + for section_type, items in sections.items(): + if items and section_type != 'chart_count': + print(f" • {section_type}: {len(items)} sections") + print(f" • Individual charts found: {sections['chart_count']}") + + return sections + def scrape_all_genres(self, tracks_per_genre: int = 100, max_workers: int = 5, include_images: bool = False) -> Dict[str, List[Dict]]: """Scrape all genres in parallel""" # Discover genres dynamically if not already done diff --git a/web_server.py b/web_server.py index b660de69..debdab77 100644 --- a/web_server.py +++ b/web_server.py @@ -11942,6 +11942,275 @@ def get_beatport_genre_tracks(genre_slug, genre_id): "count": 0 }), 500 +@app.route('/api/beatport/genre///top-10', methods=['GET']) +def get_beatport_genre_top_10(genre_slug, genre_id): + """Get top 10 tracks for a specific Beatport genre""" + try: + logger.info(f"🔥 API request for {genre_slug} genre top 10 tracks (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Scrape top 10 tracks for this genre + tracks = scraper.scrape_genre_top_10(genre) + + logger.info(f"✅ Successfully scraped {len(tracks)} top 10 tracks for {genre_slug}") + + return jsonify({ + "success": True, + "tracks": tracks, + "genre": genre, + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching top 10 tracks for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre///releases-top-10', methods=['GET']) +def get_beatport_genre_releases_top_10(genre_slug, genre_id): + """Get top 10 releases for a specific Beatport genre""" + try: + logger.info(f"📊 API request for {genre_slug} genre top 10 releases (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Scrape top 10 releases for this genre + releases = scraper.scrape_genre_releases(genre, limit=10) + + logger.info(f"✅ Successfully scraped {len(releases)} top 10 releases for {genre_slug}") + + return jsonify({ + "success": True, + "tracks": releases, + "genre": genre, + "count": len(releases) + }) + + except Exception as e: + logger.error(f"❌ Error fetching top 10 releases for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre///releases-top-100', methods=['GET']) +def get_beatport_genre_releases_top_100(genre_slug, genre_id): + """Get top 100 releases for a specific Beatport genre""" + try: + logger.info(f"📊 API request for {genre_slug} genre top 100 releases (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '100')) + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Scrape top releases for this genre + releases = scraper.scrape_genre_releases(genre, limit=limit) + + logger.info(f"✅ Successfully scraped {len(releases)} top 100 releases for {genre_slug}") + + return jsonify({ + "success": True, + "tracks": releases, + "genre": genre, + "count": len(releases) + }) + + except Exception as e: + logger.error(f"❌ Error fetching top 100 releases for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre///staff-picks', methods=['GET']) +def get_beatport_genre_staff_picks(genre_slug, genre_id): + """Get staff picks for a specific Beatport genre""" + try: + logger.info(f"⭐ API request for {genre_slug} genre staff picks (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '50')) + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Scrape staff picks for this genre + tracks = scraper.scrape_genre_staff_picks(genre, limit=limit) + + logger.info(f"✅ Successfully scraped {len(tracks)} staff picks for {genre_slug}") + + return jsonify({ + "success": True, + "tracks": tracks, + "genre": genre, + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching staff picks for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre///latest-releases', methods=['GET']) +def get_beatport_genre_latest_releases(genre_slug, genre_id): + """Get latest releases for a specific Beatport genre""" + try: + logger.info(f"🕒 API request for {genre_slug} genre latest releases (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '50')) + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Scrape latest releases for this genre + tracks = scraper.scrape_genre_latest_releases(genre, limit=limit) + + logger.info(f"✅ Successfully scraped {len(tracks)} latest releases for {genre_slug}") + + return jsonify({ + "success": True, + "tracks": tracks, + "genre": genre, + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching latest releases for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre///new-charts', methods=['GET']) +def get_beatport_genre_new_charts(genre_slug, genre_id): + """Get new charts for a specific Beatport genre""" + try: + logger.info(f"📈 API request for {genre_slug} genre new charts (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Get query parameters + limit = int(request.args.get('limit', '50')) + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Scrape new charts for this genre + tracks = scraper.scrape_genre_new_charts(genre, limit=limit) + + logger.info(f"✅ Successfully scraped {len(tracks)} new charts for {genre_slug}") + + return jsonify({ + "success": True, + "tracks": tracks, + "genre": genre, + "count": len(tracks) + }) + + except Exception as e: + logger.error(f"❌ Error fetching new charts for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "tracks": [], + "count": 0 + }), 500 + +@app.route('/api/beatport/genre///sections', methods=['GET']) +def get_beatport_genre_sections(genre_slug, genre_id): + """Discover all available sections for a specific Beatport genre""" + try: + logger.info(f"🔍 API request for {genre_slug} genre sections discovery (ID: {genre_id})") + + # Initialize the Beatport scraper + scraper = BeatportUnifiedScraper() + + # Create genre dict for scraper + genre = { + 'name': genre_slug.replace('-', ' ').title(), + 'slug': genre_slug, + 'id': genre_id + } + + # Discover sections for this genre + sections = scraper.discover_genre_page_sections(genre) + + logger.info(f"✅ Successfully discovered sections for {genre_slug}") + + return jsonify({ + "success": True, + "sections": sections, + "genre": genre + }) + + except Exception as e: + logger.error(f"❌ Error discovering sections for {genre_slug}: {e}") + return jsonify({ + "success": False, + "error": str(e), + "sections": {} + }), 500 + @app.route('/api/beatport/top-100', methods=['GET']) def get_beatport_top_100(): """Get Beatport Top 100 tracks""" diff --git a/webui/index.html b/webui/index.html index 1f8ba9f1..93661393 100644 --- a/webui/index.html +++ b/webui/index.html @@ -539,6 +539,128 @@ + +
+
+ + Browse Charts > Genre Explorer > Loading... +
+
+
+

Loading Genre...

+

Explore all chart types for this genre

+
+
+ +
+

📊 Main Charts

+
+
+
🔥
+
+

Top 10

+

Current hottest tracks

+ 10 tracks +
+
+
+
💯
+
+

Top 100

+

Complete chart rankings

+ 100 tracks +
+
+
+
+ + +
+

🎵 Releases

+
+
+
🆕
+
+

Top 10 Releases

+

Newest releases trending

+ 10 releases +
+
+
+
📊
+
+

Top 100 Releases

+

All trending releases

+ 100 releases +
+
+
+
🕒
+
+

Latest Releases

+

Recently published

+ ~ releases +
+
+
+
+ + +
+

⭐ Editorial

+
+
+
+
+

Staff Picks

+

Editor curated selection

+ ~ tracks +
+
+
+
+ + +
+

📈 New Charts Collection

+

Artist and DJ curated chart collections

+ + +
+
+
+

Loading chart collections...

+
+
+ +
+
+
+
+ + +
+
+ + Browse Charts > Genre Explorer > Genre Charts > New Charts +
+
+
+

Loading Charts...

+

Browse all available chart collections for this genre

+
+
+
+
+
+

🔍 Loading chart collections...

+
+
+ +
+
+
+
diff --git a/webui/static/script.js b/webui/static/script.js index c63cc7a5..8da497e9 100644 --- a/webui/static/script.js +++ b/webui/static/script.js @@ -9746,7 +9746,14 @@ function initializeSyncPage() { const beatportBackButtons = document.querySelectorAll('.breadcrumb-back'); beatportBackButtons.forEach(button => { button.addEventListener('click', () => { - showBeatportMainView(); + // Handle different back button types + if (button.id === 'genre-detail-back') { + showBeatportGenresView(); + } else if (button.id === 'genre-charts-list-back') { + showBeatportGenreDetailViewFromBack(); + } else { + showBeatportMainView(); + } }); }); @@ -11345,11 +11352,624 @@ async function handleBeatportChartClick(chartType, chartId, chartName, chartEndp } } -function handleBeatportGenreClick(genreSlug, genreId) { - console.log(`🎵 Beatport genre clicked: ${genreSlug} (${genreId})`); +function handleBeatportGenreClick(genreSlug, genreId, genreName) { + console.log(`🎵 Beatport genre clicked: ${genreName} (${genreSlug}/${genreId}) - SHOWING GENRE DETAIL VIEW`); + console.log(`📝 Debug: Parameters received - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`); - // Placeholder for Phase 2 - will open discovery modal - showToast(`🎵 Genre "${genreSlug}" selected - Discovery modal coming in Phase 2!`, 'info'); + // Navigate to genre detail view with proper parameters + showBeatportGenreDetailView(genreSlug, genreId, genreName); +} + +function showBeatportGenreDetailView(genreSlug, genreId, genreName) { + console.log(`🎯 Showing genre detail view for: ${genreName}`); + console.log(`📝 Debug: Function called with - Slug: ${genreSlug}, ID: ${genreId}, Name: ${genreName}`); + + // Hide all other beatport views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.remove('active'); + } + + // Show genre detail view + const genreDetailView = document.getElementById('beatport-genre-detail-view'); + if (genreDetailView) { + genreDetailView.classList.add('active'); + console.log(`📝 Debug: Genre detail view element found and activated`); + + // Update view content + const titleElement = document.getElementById('genre-detail-title'); + const breadcrumbElement = document.getElementById('genre-detail-breadcrumb'); + + console.log(`📝 Debug: Title element found: ${!!titleElement}, Breadcrumb element found: ${!!breadcrumbElement}`); + + if (titleElement) { + titleElement.textContent = genreName; + console.log(`📝 Debug: Updated title to: ${genreName}`); + } + if (breadcrumbElement) { + breadcrumbElement.textContent = `Browse Charts > Genre Explorer > ${genreName} Charts`; + console.log(`📝 Debug: Updated breadcrumb`); + } + + // Update chart type titles with genre name + const chartTitles = [ + 'genre-top-10-title', + 'genre-top-100-title', + 'genre-releases-top-10-title', + 'genre-releases-top-100-title', + 'genre-staff-picks-title', + 'genre-latest-releases-title', + 'genre-new-charts-title' + ]; + + chartTitles.forEach(titleId => { + const element = document.getElementById(titleId); + if (element) { + console.log(`📝 Debug: Found chart title element: ${titleId}`); + } else { + console.log(`📝 Debug: Missing chart title element: ${titleId}`); + } + }); + + document.getElementById('genre-top-10-title').textContent = `Top 10 ${genreName}`; + document.getElementById('genre-top-100-title').textContent = `Top 100 ${genreName}`; + document.getElementById('genre-releases-top-10-title').textContent = `Top 10 ${genreName} Releases`; + document.getElementById('genre-releases-top-100-title').textContent = `Top 100 ${genreName} Releases`; + document.getElementById('genre-staff-picks-title').textContent = `${genreName} Staff Picks`; + document.getElementById('genre-latest-releases-title').textContent = `Latest ${genreName} Releases`; + + // Load new charts directly (no expansion needed) + console.log(`🔄 Auto-loading new charts for ${genreName}...`); + loadNewChartsInline(genreSlug, genreId, genreName); + + // Store current genre data for chart type handlers + genreDetailView.dataset.genreSlug = genreSlug; + genreDetailView.dataset.genreId = genreId; + genreDetailView.dataset.genreName = genreName; + + // Add click handlers to chart type cards + setupGenreChartTypeHandlers(); + + console.log(`✅ Genre detail view shown for ${genreName}`); + } else { + console.error('❌ Genre detail view element not found'); + } +} + +function setupGenreChartTypeHandlers() { + const chartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card'); + + chartTypeCards.forEach(card => { + // Remove existing listeners + card.replaceWith(card.cloneNode(true)); + }); + + // Re-select after cloning + const newChartTypeCards = document.querySelectorAll('#beatport-genre-detail-view .genre-chart-type-card'); + + newChartTypeCards.forEach(card => { + card.addEventListener('click', () => { + const chartType = card.dataset.chartType; + const genreDetailView = document.getElementById('beatport-genre-detail-view'); + const genreSlug = genreDetailView.dataset.genreSlug; + const genreId = genreDetailView.dataset.genreId; + const genreName = genreDetailView.dataset.genreName; + + // All chart types now go directly to discovery modal + handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType); + }); + }); +} + +function showBeatportGenresView() { + // Hide genre detail view and show genres view + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + const genresView = document.getElementById('beatport-genres-view'); + if (genresView) { + genresView.classList.add('active'); + } +} + +async function toggleNewChartsExpansion(genreSlug, genreId, genreName) { + console.log(`📈 Toggling new charts expansion for: ${genreName}`); + + const expandedContent = document.getElementById('new-charts-expanded'); + const expandIndicator = document.getElementById('expand-indicator'); + const chartsCount = document.getElementById('new-charts-count'); + + if (!expandedContent || !expandIndicator) { + console.error('❌ New charts expansion elements not found'); + return; + } + + // Check if already expanded + const isExpanded = expandedContent.style.display !== 'none'; + + if (isExpanded) { + // Collapse + expandedContent.style.display = 'none'; + expandIndicator.classList.remove('expanded'); + console.log('📉 Collapsed new charts section'); + } else { + // Expand and load charts + expandedContent.style.display = 'block'; + expandIndicator.classList.add('expanded'); + + // Load charts if not already loaded + await loadNewChartsInline(genreSlug, genreId, genreName); + console.log('📈 Expanded new charts section'); + } +} + +async function loadNewChartsInline(genreSlug, genreId, genreName) { + const chartsGrid = document.getElementById('new-charts-grid'); + const loadingInline = document.getElementById('charts-loading-inline'); + + if (!chartsGrid || !loadingInline) { + console.error('❌ Inline charts elements not found'); + return; + } + + // Show loading state + loadingInline.style.display = 'block'; + chartsGrid.style.display = 'none'; + chartsGrid.innerHTML = ''; + + try { + console.log(`🔍 Loading inline charts for ${genreName}...`); + + // Fetch charts from the new-charts endpoint + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=20`); + if (!response.ok) { + throw new Error(`Failed to fetch charts: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + // Show empty state + chartsGrid.innerHTML = ` +
+

No Charts Available

+

No curated charts found for ${genreName} at the moment.

+
+ `; + } else { + // Populate charts grid + const chartsHTML = data.tracks.map((chart, index) => { + const chartName = chart.title || 'Untitled Chart'; + const artistName = chart.artist || 'Various Artists'; + const chartUrl = chart.url || ''; + + return ` +
+
+
📈
+
+
${chartName}
+

by ${artistName}

+
+
+
+ Curated ${genreName} chart collection +
+ +
+ `; + }).join(''); + + chartsGrid.innerHTML = chartsHTML; + + // Add click handlers to chart items + setupNewChartItemHandlers(genreSlug, genreId, genreName); + } + + // Hide loading and show grid + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + console.log(`✅ Loaded ${data.tracks?.length || 0} inline charts for ${genreName}`); + showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success'); + + } catch (error) { + console.error(`❌ Error loading inline charts for ${genreName}:`, error); + + // Show error state + chartsGrid.innerHTML = ` +
+

Error Loading Charts

+

Unable to load chart collections for ${genreName}.

+
+ `; + + loadingInline.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + showToast(`Error loading charts: ${error.message}`, 'error'); + } +} + +function setupNewChartItemHandlers(genreSlug, genreId, genreName) { + const chartItems = document.querySelectorAll('#new-charts-grid .new-chart-item'); + + chartItems.forEach(item => { + item.addEventListener('click', async () => { + const chartName = item.dataset.chartName; + const chartArtist = item.dataset.chartArtist; + const chartUrl = item.dataset.chartUrl; + + console.log(`🎵 Chart clicked: ${chartName} by ${chartArtist}`); + + try { + // Create a virtual chart data object + const chartHash = `individual_chart_${genreSlug}_${Date.now()}`; + const fullChartName = `${chartName} (${genreName})`; + + showToast(`Loading ${chartName}...`, 'info'); + + // For demonstration, we'll use the genre tracks as chart content + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/tracks?limit=20`); + if (!response.ok) { + throw new Error(`Failed to fetch chart content: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in chart`); + } + + // Create chart data object for playlist card + const chartData = { + hash: chartHash, + name: fullChartName, + chart_type: 'individual_chart', + track_count: data.tracks.length, + tracks: data.tracks.map(track => ({ + name: track.title || 'Unknown Title', + artists: [track.artist || 'Unknown Artist'], + album: fullChartName, + duration_ms: 0, + external_urls: { beatport: track.url || chartUrl }, + source: 'beatport' + })) + }; + + // Add card to container (in background, like YouTube does) + console.log(`🃏 Creating Beatport playlist card for: ${fullChartName}`); + addBeatportCardToContainer(chartData); + + // Automatically open discovery modal + handleBeatportCardClick(chartHash); + + console.log(`✅ Created Beatport card and opened discovery modal for ${fullChartName}`); + + } catch (error) { + console.error(`❌ Error loading chart: ${error.message}`); + showToast(`Error loading chart: ${error.message}`, 'error'); + } + }); + }); +} + +function showBeatportGenreDetailViewFromBack() { + // Show genre detail view (used by charts list back button) + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + + const genreDetailView = document.getElementById('beatport-genre-detail-view'); + if (genreDetailView) { + genreDetailView.classList.add('active'); + } +} + +async function showBeatportGenreChartsListView(genreSlug, genreId, genreName) { + console.log(`📈 Showing charts list for: ${genreName}`); + + // Hide all other beatport views + document.querySelectorAll('.beatport-sub-view').forEach(view => { + view.classList.remove('active'); + }); + const mainView = document.getElementById('beatport-main-view'); + if (mainView) { + mainView.classList.remove('active'); + } + + // Show charts list view + const chartsListView = document.getElementById('beatport-genre-charts-list-view'); + if (chartsListView) { + chartsListView.classList.add('active'); + + // Update view content + document.getElementById('genre-charts-list-title').textContent = `New ${genreName} Charts`; + document.getElementById('genre-charts-list-breadcrumb').textContent = `Browse Charts > Genre Explorer > ${genreName} Charts > New Charts`; + + // Store current genre data for individual chart handlers + chartsListView.dataset.genreSlug = genreSlug; + chartsListView.dataset.genreId = genreId; + chartsListView.dataset.genreName = genreName; + + // Load charts for this genre + await loadGenreChartsList(genreSlug, genreId, genreName); + + console.log(`✅ Charts list view shown for ${genreName}`); + } else { + console.error('❌ Charts list view element not found'); + } +} + +async function loadGenreChartsList(genreSlug, genreId, genreName) { + const chartsGrid = document.getElementById('genre-charts-grid'); + const loadingPlaceholder = document.getElementById('charts-loading-placeholder'); + + if (!chartsGrid || !loadingPlaceholder) { + console.error('❌ Charts grid or loading placeholder not found'); + return; + } + + // Show loading state + loadingPlaceholder.style.display = 'block'; + chartsGrid.style.display = 'none'; + chartsGrid.innerHTML = ''; + + try { + console.log(`🔍 Loading charts for ${genreName}...`); + + // Fetch charts from the new-charts endpoint + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/new-charts?limit=50`); + if (!response.ok) { + throw new Error(`Failed to fetch charts: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + // Show empty state + chartsGrid.innerHTML = ` +
+

No Charts Available

+

No curated charts found for ${genreName} at the moment.
Check back later for new DJ and artist chart collections.

+
+ `; + } else { + // Populate charts grid + const chartsHTML = data.tracks.map((chart, index) => { + const chartName = chart.title || 'Untitled Chart'; + const artistName = chart.artist || 'Various Artists'; + const chartUrl = chart.url || ''; + + // Extract chart ID from URL for click handling + const chartId = chartUrl.split('/').pop() || `chart_${index}`; + + return ` +
+
+
📈
+
+

${chartName}

+

by ${artistName}

+
+
+
+ Curated chart collection featuring ${genreName} tracks +
+ +
+ `; + }).join(''); + + chartsGrid.innerHTML = chartsHTML; + + // Add click handlers to chart items + setupGenreChartItemHandlers(genreSlug, genreId, genreName); + } + + // Hide loading and show grid + loadingPlaceholder.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + console.log(`✅ Loaded ${data.tracks?.length || 0} charts for ${genreName}`); + showToast(`Found ${data.tracks?.length || 0} chart collections`, 'success'); + + } catch (error) { + console.error(`❌ Error loading charts for ${genreName}:`, error); + + // Show error state + chartsGrid.innerHTML = ` +
+

Error Loading Charts

+

Unable to load chart collections for ${genreName}.
Please try again later.

+
+ `; + + loadingPlaceholder.style.display = 'none'; + chartsGrid.style.display = 'grid'; + + showToast(`Error loading charts: ${error.message}`, 'error'); + } +} + +function setupGenreChartItemHandlers(genreSlug, genreId, genreName) { + const chartItems = document.querySelectorAll('#genre-charts-grid .genre-chart-item'); + + chartItems.forEach(item => { + item.addEventListener('click', async () => { + const chartName = item.dataset.chartName; + const chartArtist = item.dataset.chartArtist; + const chartUrl = item.dataset.chartUrl; + + console.log(`🎵 Chart clicked: ${chartName} by ${chartArtist}`); + + // For now, we'll create a virtual playlist from this chart + // This would eventually fetch the actual chart contents from Beatport + try { + // Create a virtual chart data object + const chartHash = `individual_chart_${genreSlug}_${Date.now()}`; + const fullChartName = `${chartName} (${genreName})`; + + showToast(`Loading ${chartName}...`, 'info'); + + // For demonstration, we'll use the genre tracks as chart content + // In a real implementation, this would fetch the specific chart tracks + const response = await fetch(`/api/beatport/genre/${genreSlug}/${genreId}/tracks?limit=20`); + if (!response.ok) { + throw new Error(`Failed to fetch chart content: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in chart`); + } + + // Create chart data object for playlist card + const chartData = { + hash: chartHash, + name: fullChartName, + chart_type: 'individual_chart', + track_count: data.tracks.length, + tracks: data.tracks.map(track => ({ + name: track.title || 'Unknown Title', + artists: [track.artist || 'Unknown Artist'], + album: fullChartName, + duration_ms: 0, + external_urls: { beatport: track.url || chartUrl }, + source: 'beatport' + })) + }; + + // Add card to container (in background, like YouTube does) + console.log(`🃏 Creating Beatport playlist card for: ${fullChartName}`); + addBeatportCardToContainer(chartData); + + // Automatically open discovery modal + handleBeatportCardClick(chartHash); + + console.log(`✅ Created Beatport card and opened discovery modal for ${fullChartName}`); + + } catch (error) { + console.error(`❌ Error loading chart: ${error.message}`); + showToast(`Error loading chart: ${error.message}`, 'error'); + } + }); + }); +} + +async function handleGenreChartTypeClick(genreSlug, genreId, genreName, chartType) { + console.log(`🎯 Genre chart type clicked: ${chartType} for ${genreName} (${genreSlug}/${genreId})`); + + // Map chart types to API endpoints and create descriptive names + const chartTypeMap = { + 'top-10': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/top-10`, + name: `Top 10 ${genreName}`, + limit: 10 + }, + 'top-100': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/tracks`, + name: `Top 100 ${genreName}`, + limit: 100 + }, + 'releases-top-10': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-10`, + name: `Top 10 ${genreName} Releases`, + limit: 10 + }, + 'releases-top-100': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/releases-top-100`, + name: `Top 100 ${genreName} Releases`, + limit: 100 + }, + 'staff-picks': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/staff-picks`, + name: `${genreName} Staff Picks`, + limit: 50 + }, + 'latest-releases': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/latest-releases`, + name: `Latest ${genreName} Releases`, + limit: 50 + }, + 'new-charts': { + endpoint: `/api/beatport/genre/${genreSlug}/${genreId}/new-charts`, + name: `New ${genreName} Charts`, + limit: 50 + } + }; + + const chartConfig = chartTypeMap[chartType]; + if (!chartConfig) { + console.error(`❌ Unknown chart type: ${chartType}`); + showToast(`Unknown chart type: ${chartType}`, 'error'); + return; + } + + try { + // Check if we already have a card for this specific chart type + const existingState = Object.values(beatportChartStates).find(state => + state.chart && state.chart.name === chartConfig.name && state.chart.chart_type === `genre_${chartType}` + ); + + if (existingState) { + console.log(`🔄 Found existing Beatport card for ${chartConfig.name}, opening existing modal`); + handleBeatportCardClick(existingState.chart.hash); + return; + } + + // Create a chart hash for state management + const chartHash = `genre_${chartType}_${genreSlug}_${genreId}_${Date.now()}`; + + showToast(`Loading ${chartConfig.name}...`, 'info'); + + // Fetch tracks from the specific endpoint + const response = await fetch(`${chartConfig.endpoint}?limit=${chartConfig.limit}`); + if (!response.ok) { + throw new Error(`Failed to fetch ${chartConfig.name}: ${response.status}`); + } + + const data = await response.json(); + if (!data.success || !data.tracks || data.tracks.length === 0) { + throw new Error(`No tracks found in ${chartConfig.name}`); + } + + // Create chart data object for playlist card + const chartData = { + hash: chartHash, + name: chartConfig.name, + chart_type: `genre_${chartType}`, + track_count: data.tracks.length, + tracks: data.tracks.map(track => ({ + name: track.title || 'Unknown Title', + artists: [track.artist || 'Unknown Artist'], + album: chartConfig.name, + duration_ms: 0, + external_urls: { beatport: track.url || '' }, + source: 'beatport' + })) + }; + + // Add card to container (in background, like YouTube does) + console.log(`🃏 Creating Beatport playlist card for: ${chartConfig.name}`); + addBeatportCardToContainer(chartData); + + // Automatically open discovery modal (like when you click a YouTube or Tidal card in fresh state) + handleBeatportCardClick(chartHash); + + console.log(`✅ Created Beatport card and opened discovery modal for ${chartConfig.name}`); + + } catch (error) { + console.error(`❌ Error loading ${chartConfig.name}:`, error); + showToast(`Error loading ${chartConfig.name}: ${error.message}`, 'error'); + } } // =============================== diff --git a/webui/static/style.css b/webui/static/style.css index 4ec0e5b3..5d8c9a0f 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -4862,6 +4862,622 @@ body { 0 0 20px rgba(1, 255, 149, 0.3); } +/* ================================= */ +/* BEATPORT GENRE DETAIL VIEW */ +/* ================================= */ + +.genre-detail-header { + margin-bottom: 30px; + padding: 20px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.genre-detail-info h2 { + font-size: 32px; + font-weight: 700; + color: #ffffff; + margin: 0 0 8px 0; + background: linear-gradient(135deg, #01ff95, #00d4ff); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.genre-detail-info p { + font-size: 16px; + color: rgba(255, 255, 255, 0.7); + margin: 0; +} + +/* Section Styling */ +.genre-main-charts-section, +.genre-releases-section, +.genre-editorial-section, +.genre-new-charts-section { + margin-bottom: 40px; + padding: 20px; + background: linear-gradient(135deg, + rgba(20, 20, 20, 0.8) 0%, + rgba(15, 15, 15, 0.9) 100%); + border-radius: 16px; + border: 1px solid rgba(255, 255, 255, 0.08); +} + +.section-title { + font-size: 20px; + font-weight: 600; + color: #ffffff; + margin: 0 0 16px 0; + display: flex; + align-items: center; + gap: 8px; +} + +.section-description { + font-size: 14px; + color: rgba(255, 255, 255, 0.6); + margin: 0 0 20px 0; + font-style: italic; +} + +/* Chart Types Grid */ +.genre-chart-types-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 16px; +} + +/* Chart Type Cards */ +.genre-chart-type-card { + background: linear-gradient(135deg, + rgba(25, 25, 25, 0.95) 0%, + rgba(15, 15, 15, 0.98) 100%); + backdrop-filter: blur(20px); + border-radius: 12px; + border: 1px solid rgba(255, 255, 255, 0.1); + padding: 20px; + display: flex; + align-items: center; + gap: 16px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.genre-chart-type-card::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(1, 255, 149, 0.1) 0%, + transparent 50%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.genre-chart-type-card:hover { + transform: translateY(-3px); + border-color: rgba(1, 255, 149, 0.3); + box-shadow: + 0 10px 25px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(1, 255, 149, 0.2); +} + +.genre-chart-type-card:hover::before { + opacity: 1; +} + +.chart-type-icon { + font-size: 32px; + min-width: 50px; + height: 50px; + display: flex; + align-items: center; + justify-content: center; + background: linear-gradient(135deg, + rgba(1, 255, 149, 0.2) 0%, + rgba(0, 212, 255, 0.2) 100%); + border-radius: 12px; + border: 1px solid rgba(1, 255, 149, 0.3); + position: relative; + z-index: 2; +} + +.chart-type-info { + flex: 1; + position: relative; + z-index: 2; +} + +.chart-type-info h3 { + font-size: 18px; + font-weight: 600; + color: #ffffff; + margin: 0 0 4px 0; +} + +.chart-type-info p { + font-size: 14px; + color: rgba(255, 255, 255, 0.7); + margin: 0 0 8px 0; +} + +.track-count { + font-size: 12px; + color: rgba(1, 255, 149, 0.8); + font-weight: 500; + background: rgba(1, 255, 149, 0.1); + padding: 4px 8px; + border-radius: 6px; + display: inline-block; +} + +/* Special Chart Card (New Charts) */ +.genre-new-charts-container { + display: flex; + justify-content: center; +} + +.genre-chart-type-card.special-chart { + background: linear-gradient(135deg, + rgba(30, 20, 40, 0.95) 0%, + rgba(20, 15, 30, 0.98) 100%); + border: 2px solid rgba(138, 43, 226, 0.3); + max-width: 400px; + position: relative; +} + +.genre-chart-type-card.special-chart::before { + background: linear-gradient(135deg, + rgba(138, 43, 226, 0.15) 0%, + transparent 50%); +} + +.genre-chart-type-card.special-chart:hover { + border-color: rgba(138, 43, 226, 0.5); + box-shadow: + 0 12px 30px rgba(0, 0, 0, 0.4), + 0 0 20px rgba(138, 43, 226, 0.3); +} + +.genre-chart-type-card.special-chart .chart-type-icon { + background: linear-gradient(135deg, + rgba(138, 43, 226, 0.3) 0%, + rgba(75, 0, 130, 0.3) 100%); + border-color: rgba(138, 43, 226, 0.4); +} + +.special-badge { + position: absolute; + top: 8px; + right: 8px; + background: linear-gradient(135deg, #8a2be2, #4b0082); + color: #ffffff; + font-size: 10px; + font-weight: 600; + padding: 4px 8px; + border-radius: 6px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.expand-indicator { + position: absolute; + top: 50%; + right: 12px; + transform: translateY(-50%); + font-size: 16px; + color: rgba(138, 43, 226, 0.8); + transition: transform 0.3s ease; + z-index: 5; + background: rgba(0, 0, 0, 0.3); + padding: 4px; + border-radius: 4px; +} + +.expand-indicator.expanded { + transform: translateY(-50%) rotate(180deg); +} + +/* Always Visible New Charts Content */ +.new-charts-content { + margin-top: 20px; + padding: 20px; + background: linear-gradient(135deg, + rgba(35, 25, 45, 0.9) 0%, + rgba(25, 20, 35, 0.95) 100%); + border-radius: 12px; + border: 1px solid rgba(138, 43, 226, 0.15); +} + +@keyframes expandDown { + from { + opacity: 0; + max-height: 0; + padding-top: 0; + padding-bottom: 0; + } + to { + opacity: 1; + max-height: 1000px; + padding-top: 20px; + padding-bottom: 20px; + } +} + +.charts-loading-inline { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.6); +} + +.charts-loading-inline .loading-spinner-small { + width: 30px; + height: 30px; + border: 3px solid rgba(138, 43, 226, 0.3); + border-top: 3px solid #8a2be2; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 12px auto; +} + +.charts-loading-inline p { + font-size: 14px; + margin: 0; +} + +.new-charts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 16px; +} + +.new-chart-item { + background: linear-gradient(135deg, + rgba(25, 15, 35, 0.95) 0%, + rgba(15, 10, 25, 0.98) 100%); + backdrop-filter: blur(15px); + border-radius: 10px; + border: 1px solid rgba(138, 43, 226, 0.2); + padding: 16px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.new-chart-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(138, 43, 226, 0.1) 0%, + transparent 50%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.new-chart-item:hover { + transform: translateY(-3px); + border-color: rgba(138, 43, 226, 0.4); + box-shadow: + 0 8px 20px rgba(0, 0, 0, 0.3), + 0 0 15px rgba(138, 43, 226, 0.2); +} + +.new-chart-item:hover::before { + opacity: 1; +} + +.new-chart-header { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 10px; + position: relative; + z-index: 2; +} + +.new-chart-icon { + width: 32px; + height: 32px; + background: linear-gradient(135deg, + rgba(138, 43, 226, 0.3) 0%, + rgba(75, 0, 130, 0.3) 100%); + border-radius: 6px; + display: flex; + align-items: center; + justify-content: center; + font-size: 14px; + border: 1px solid rgba(138, 43, 226, 0.4); +} + +.new-chart-title { + flex: 1; +} + +.new-chart-title h5 { + font-size: 14px; + font-weight: 600; + color: #ffffff; + margin: 0 0 2px 0; + line-height: 1.3; +} + +.new-chart-artist { + font-size: 12px; + color: rgba(138, 43, 226, 0.8); + margin: 0; + font-weight: 500; +} + +.new-chart-description { + font-size: 11px; + color: rgba(255, 255, 255, 0.5); + margin: 0 0 10px 0; + line-height: 1.4; + position: relative; + z-index: 2; +} + +.new-chart-footer { + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + z-index: 2; +} + +.new-chart-type { + font-size: 9px; + background: rgba(138, 43, 226, 0.2); + color: rgba(138, 43, 226, 0.9); + padding: 3px 6px; + border-radius: 4px; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.5px; +} + +.new-chart-action { + font-size: 10px; + color: rgba(1, 255, 149, 0.8); + font-weight: 500; + display: flex; + align-items: center; + gap: 3px; +} + +.new-charts-empty { + text-align: center; + padding: 40px 20px; + color: rgba(255, 255, 255, 0.6); +} + +.new-charts-empty h4 { + font-size: 16px; + margin: 0 0 8px 0; + color: rgba(255, 255, 255, 0.8); +} + +.new-charts-empty p { + font-size: 12px; + margin: 0; + line-height: 1.4; +} + +/* ================================= */ +/* BEATPORT GENRE CHARTS LIST VIEW */ +/* ================================= */ + +.genre-charts-list-header { + margin-bottom: 30px; + padding: 20px 0; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.genre-charts-list-info h2 { + font-size: 28px; + font-weight: 700; + color: #ffffff; + margin: 0 0 8px 0; + background: linear-gradient(135deg, #8a2be2, #4b0082); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +.genre-charts-list-info p { + font-size: 16px; + color: rgba(255, 255, 255, 0.7); + margin: 0; +} + +.genre-charts-list-container { + position: relative; + min-height: 400px; +} + +.charts-loading-placeholder { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + text-align: center; + color: rgba(255, 255, 255, 0.6); +} + +.charts-loading-placeholder .loading-spinner { + width: 40px; + height: 40px; + border: 4px solid rgba(138, 43, 226, 0.3); + border-top: 4px solid #8a2be2; + border-radius: 50%; + animation: spin 1s linear infinite; + margin: 0 auto 16px auto; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.charts-loading-placeholder p { + font-size: 16px; + margin: 0; +} + +.genre-charts-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); + gap: 20px; + padding: 10px 0; +} + +.genre-chart-item { + background: linear-gradient(135deg, + rgba(30, 20, 40, 0.95) 0%, + rgba(20, 15, 30, 0.98) 100%); + backdrop-filter: blur(20px); + border-radius: 12px; + border: 1px solid rgba(138, 43, 226, 0.2); + padding: 20px; + cursor: pointer; + transition: all 0.3s ease; + position: relative; + overflow: hidden; +} + +.genre-chart-item::before { + content: ''; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: linear-gradient(135deg, + rgba(138, 43, 226, 0.1) 0%, + transparent 50%); + opacity: 0; + transition: opacity 0.3s ease; +} + +.genre-chart-item:hover { + transform: translateY(-5px); + border-color: rgba(138, 43, 226, 0.4); + box-shadow: + 0 15px 30px rgba(0, 0, 0, 0.3), + 0 0 20px rgba(138, 43, 226, 0.3); +} + +.genre-chart-item:hover::before { + opacity: 1; +} + +.chart-item-header { + display: flex; + align-items: center; + gap: 12px; + margin-bottom: 12px; + position: relative; + z-index: 2; +} + +.chart-item-icon { + width: 40px; + height: 40px; + background: linear-gradient(135deg, + rgba(138, 43, 226, 0.3) 0%, + rgba(75, 0, 130, 0.3) 100%); + border-radius: 8px; + display: flex; + align-items: center; + justify-content: center; + font-size: 18px; + border: 1px solid rgba(138, 43, 226, 0.4); +} + +.chart-item-title { + flex: 1; +} + +.chart-item-title h4 { + font-size: 16px; + font-weight: 600; + color: #ffffff; + margin: 0 0 4px 0; + line-height: 1.3; +} + +.chart-item-artist { + font-size: 14px; + color: rgba(138, 43, 226, 0.8); + margin: 0; + font-weight: 500; +} + +.chart-item-description { + font-size: 13px; + color: rgba(255, 255, 255, 0.6); + margin: 0 0 12px 0; + line-height: 1.4; + position: relative; + z-index: 2; +} + +.chart-item-footer { + display: flex; + justify-content: space-between; + align-items: center; + position: relative; + z-index: 2; +} + +.chart-item-type { + font-size: 11px; + background: rgba(138, 43, 226, 0.2); + color: rgba(138, 43, 226, 0.9); + padding: 4px 8px; + border-radius: 6px; + text-transform: uppercase; + font-weight: 600; + letter-spacing: 0.5px; +} + +.chart-item-action { + font-size: 12px; + color: rgba(1, 255, 149, 0.8); + font-weight: 500; + display: flex; + align-items: center; + gap: 4px; +} + +.genre-charts-empty { + text-align: center; + padding: 60px 20px; + color: rgba(255, 255, 255, 0.6); +} + +.genre-charts-empty h3 { + font-size: 20px; + margin: 0 0 12px 0; + color: rgba(255, 255, 255, 0.8); +} + +.genre-charts-empty p { + font-size: 14px; + margin: 0; + line-height: 1.5; +} + /* ================================= */ /* RESPONSIVE DESIGN */ /* ================================= */ @@ -4872,6 +5488,71 @@ body { gap: 15px; } + /* Genre Detail Responsive */ + .genre-detail-info h2 { + font-size: 24px; + } + + .genre-chart-types-grid { + grid-template-columns: 1fr; + gap: 12px; + } + + .genre-chart-type-card { + padding: 16px; + gap: 12px; + } + + .chart-type-icon { + font-size: 28px; + min-width: 40px; + height: 40px; + } + + .chart-type-info h3 { + font-size: 16px; + } + + .section-title { + font-size: 18px; + } + + .genre-main-charts-section, + .genre-releases-section, + .genre-editorial-section, + .genre-new-charts-section { + margin-bottom: 24px; + padding: 16px; + } + + /* Genre Charts List Responsive */ + .genre-charts-list-info h2 { + font-size: 22px; + } + + .genre-charts-grid { + grid-template-columns: 1fr; + gap: 16px; + } + + .genre-chart-item { + padding: 16px; + } + + .chart-item-header { + gap: 10px; + } + + .chart-item-icon { + width: 35px; + height: 35px; + font-size: 16px; + } + + .chart-item-title h4 { + font-size: 15px; + } + .beatport-chart-item { flex-direction: column; text-align: center;