beatport progress
This commit is contained in:
parent
c421a3b02c
commit
c7377e4ac7
4 changed files with 958 additions and 62 deletions
|
|
@ -26,28 +26,8 @@ class BeatportUnifiedScraper:
|
||||||
# Dynamic genres - will be populated by scraping homepage
|
# Dynamic genres - will be populated by scraping homepage
|
||||||
self.all_genres = []
|
self.all_genres = []
|
||||||
|
|
||||||
def clean_text(self, text):
|
# Current Beatport genres with correct URLs and IDs (updated from live site)
|
||||||
"""Clean and normalize text from HTML elements"""
|
|
||||||
if not text:
|
|
||||||
return text
|
|
||||||
|
|
||||||
# Fix common spacing issues
|
|
||||||
text = re.sub(r'([a-z$!@#%&*])([A-Z])', r'\1 \2', text) # Add space between lowercase/symbols and uppercase
|
|
||||||
text = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', text) # Add space between letter and number
|
|
||||||
text = re.sub(r'(\d)([a-zA-Z])', r'\1 \2', text) # Add space between number and letter
|
|
||||||
text = re.sub(r'([a-zA-Z]),([a-zA-Z])', r'\1, \2', text) # Add space after comma
|
|
||||||
text = re.sub(r'([a-zA-Z])Mix\b', r'\1 Mix', text) # Fix "hitMix" -> "hit Mix"
|
|
||||||
text = re.sub(r'([a-zA-Z])Remix\b', r'\1 Remix', text) # Fix "hitRemix" -> "hit Remix"
|
|
||||||
text = re.sub(r'([a-zA-Z])Extended\b', r'\1 Extended', text) # Fix "hitExtended" -> "hit Extended"
|
|
||||||
text = re.sub(r'([a-zA-Z])Version\b', r'\1 Version', text) # Fix "hitVersion" -> "hit Version"
|
|
||||||
text = re.sub(r'\s+', ' ', text) # Collapse multiple spaces
|
|
||||||
text = text.strip()
|
|
||||||
|
|
||||||
return text
|
|
||||||
|
|
||||||
# Comprehensive fallback genres based on current Beatport dropdown (39 genres)
|
|
||||||
self.fallback_genres = [
|
self.fallback_genres = [
|
||||||
# Electronic genres
|
|
||||||
{'name': '140 / Deep Dubstep / Grime', 'slug': '140-deep-dubstep-grime', 'id': '95', 'url': f'{self.base_url}/genre/140-deep-dubstep-grime/95'},
|
{'name': '140 / Deep Dubstep / Grime', 'slug': '140-deep-dubstep-grime', 'id': '95', 'url': f'{self.base_url}/genre/140-deep-dubstep-grime/95'},
|
||||||
{'name': 'Afro House', 'slug': 'afro-house', 'id': '89', 'url': f'{self.base_url}/genre/afro-house/89'},
|
{'name': 'Afro House', 'slug': 'afro-house', 'id': '89', 'url': f'{self.base_url}/genre/afro-house/89'},
|
||||||
{'name': 'Amapiano', 'slug': 'amapiano', 'id': '98', 'url': f'{self.base_url}/genre/amapiano/98'},
|
{'name': 'Amapiano', 'slug': 'amapiano', 'id': '98', 'url': f'{self.base_url}/genre/amapiano/98'},
|
||||||
|
|
@ -84,7 +64,7 @@ class BeatportUnifiedScraper:
|
||||||
{'name': 'Trance (Raw / Deep / Hypnotic)', 'slug': 'trance-raw-deep-hypnotic', 'id': '99', 'url': f'{self.base_url}/genre/trance-raw-deep-hypnotic/99'},
|
{'name': 'Trance (Raw / Deep / Hypnotic)', 'slug': 'trance-raw-deep-hypnotic', 'id': '99', 'url': f'{self.base_url}/genre/trance-raw-deep-hypnotic/99'},
|
||||||
{'name': 'Trap / Future Bass', 'slug': 'trap-future-bass', 'id': '38', 'url': f'{self.base_url}/genre/trap-future-bass/38'},
|
{'name': 'Trap / Future Bass', 'slug': 'trap-future-bass', 'id': '38', 'url': f'{self.base_url}/genre/trap-future-bass/38'},
|
||||||
{'name': 'UK Garage / Bassline', 'slug': 'uk-garage-bassline', 'id': '86', 'url': f'{self.base_url}/genre/uk-garage-bassline/86'},
|
{'name': 'UK Garage / Bassline', 'slug': 'uk-garage-bassline', 'id': '86', 'url': f'{self.base_url}/genre/uk-garage-bassline/86'},
|
||||||
# Open Format genres
|
# Additional genres from current Beatport
|
||||||
{'name': 'African', 'slug': 'african', 'id': '102', 'url': f'{self.base_url}/genre/african/102'},
|
{'name': 'African', 'slug': 'african', 'id': '102', 'url': f'{self.base_url}/genre/african/102'},
|
||||||
{'name': 'Caribbean', 'slug': 'caribbean', 'id': '103', 'url': f'{self.base_url}/genre/caribbean/103'},
|
{'name': 'Caribbean', 'slug': 'caribbean', 'id': '103', 'url': f'{self.base_url}/genre/caribbean/103'},
|
||||||
{'name': 'Hip-Hop', 'slug': 'hip-hop', 'id': '105', 'url': f'{self.base_url}/genre/hip-hop/105'},
|
{'name': 'Hip-Hop', 'slug': 'hip-hop', 'id': '105', 'url': f'{self.base_url}/genre/hip-hop/105'},
|
||||||
|
|
@ -93,6 +73,53 @@ class BeatportUnifiedScraper:
|
||||||
{'name': 'R&B', 'slug': 'rb', 'id': '108', 'url': f'{self.base_url}/genre/rb/108'}
|
{'name': 'R&B', 'slug': 'rb', 'id': '108', 'url': f'{self.base_url}/genre/rb/108'}
|
||||||
]
|
]
|
||||||
|
|
||||||
|
def clean_text(self, text):
|
||||||
|
"""Clean and normalize text from HTML elements"""
|
||||||
|
if not text:
|
||||||
|
return text
|
||||||
|
|
||||||
|
# Fix common spacing issues
|
||||||
|
text = re.sub(r'([a-z$!@#%&*])([A-Z])', r'\1 \2', text) # Add space between lowercase/symbols and uppercase
|
||||||
|
text = re.sub(r'([a-zA-Z])(\d)', r'\1 \2', text) # Add space between letter and number
|
||||||
|
text = re.sub(r'(\d)([a-zA-Z])', r'\1 \2', text) # Add space between number and letter
|
||||||
|
text = re.sub(r'([a-zA-Z]),([a-zA-Z])', r'\1, \2', text) # Add space after comma
|
||||||
|
text = re.sub(r'([a-zA-Z])Mix\b', r'\1 Mix', text) # Fix "hitMix" -> "hit Mix"
|
||||||
|
text = re.sub(r'([a-zA-Z])Remix\b', r'\1 Remix', text) # Fix "hitRemix" -> "hit Remix"
|
||||||
|
text = re.sub(r'([a-zA-Z])Extended\b', r'\1 Extended', text) # Fix "hitExtended" -> "hit Extended"
|
||||||
|
text = re.sub(r'([a-zA-Z])Version\b', r'\1 Version', text) # Fix "hitVersion" -> "hit Version"
|
||||||
|
text = re.sub(r'\s+', ' ', text) # Collapse multiple spaces
|
||||||
|
text = text.strip()
|
||||||
|
|
||||||
|
return text
|
||||||
|
|
||||||
|
def _is_valid_genre_name(self, name: str) -> bool:
|
||||||
|
"""Check if a name is a valid genre name and not a section title"""
|
||||||
|
# Filter out common section titles
|
||||||
|
section_titles = {
|
||||||
|
'open format', 'electronic', 'genres', 'browse', 'charts',
|
||||||
|
'new releases', 'trending', 'featured', 'popular', 'top',
|
||||||
|
'main', 'explore', 'discover', 'all genres'
|
||||||
|
}
|
||||||
|
|
||||||
|
name_lower = name.lower().strip()
|
||||||
|
|
||||||
|
# Reject if it's a section title
|
||||||
|
if name_lower in section_titles:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Reject if it's too short or too generic
|
||||||
|
if len(name_lower) < 3:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Reject if it contains only common words
|
||||||
|
common_words = {'the', 'and', 'or', 'of', 'in', 'on', 'at', 'to', 'for'}
|
||||||
|
words = name_lower.split()
|
||||||
|
if len(words) == 1 and words[0] in common_words:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Accept everything else
|
||||||
|
return True
|
||||||
|
|
||||||
def get_page(self, url: str) -> Optional[BeautifulSoup]:
|
def get_page(self, url: str) -> Optional[BeautifulSoup]:
|
||||||
"""Fetch and parse a page with error handling"""
|
"""Fetch and parse a page with error handling"""
|
||||||
try:
|
try:
|
||||||
|
|
@ -154,50 +181,140 @@ class BeatportUnifiedScraper:
|
||||||
|
|
||||||
genres = []
|
genres = []
|
||||||
|
|
||||||
# Method 1: Look for genres dropdown menu (multiple selectors)
|
# Method 1: Look for the specific genres dropdown menu structure
|
||||||
potential_dropdowns = [
|
genres_dropdown = soup.find('div', {'id': 'genres-dropdown-menu'})
|
||||||
soup.find('div', {'id': 'genres-dropdown-menu'}),
|
|
||||||
soup.find('div', class_=re.compile(r'genres.*dropdown', re.I)),
|
|
||||||
soup.find('nav', class_=re.compile(r'genres', re.I)),
|
|
||||||
soup.find('div', class_=re.compile(r'dropdown.*genres', re.I)),
|
|
||||||
soup.find('ul', class_=re.compile(r'genres', re.I)),
|
|
||||||
soup.find('div', {'data-testid': 'genres-dropdown'}),
|
|
||||||
soup.find('div', {'aria-label': re.compile(r'genres', re.I)})
|
|
||||||
]
|
|
||||||
|
|
||||||
for dropdown in potential_dropdowns:
|
if genres_dropdown:
|
||||||
if dropdown:
|
print("✅ Found genres-dropdown-menu")
|
||||||
print(f"✅ Found potential genres dropdown: {dropdown.name} with class {dropdown.get('class')}")
|
|
||||||
# Extract genre links from dropdown - look for the specific pattern
|
|
||||||
genre_links = dropdown.find_all('a', href=re.compile(r'/genre/[^/]+/\d+'))
|
|
||||||
|
|
||||||
if genre_links:
|
# Look for the two main div containers as described
|
||||||
print(f"🔗 Found {len(genre_links)} genre links in dropdown")
|
genre_containers = genres_dropdown.find_all('div', recursive=False)
|
||||||
for link in genre_links:
|
print(f"🔍 Found {len(genre_containers)} top-level containers in dropdown")
|
||||||
href = link.get('href', '')
|
|
||||||
# Get text content, handling nested elements
|
|
||||||
name_text = link.get_text(strip=True)
|
|
||||||
|
|
||||||
# Clean up the name - remove "New" tags and extra whitespace
|
for container_idx, container in enumerate(genre_containers):
|
||||||
name = re.sub(r'\s*New\s*', '', name_text).strip()
|
print(f"📦 Processing container {container_idx + 1}")
|
||||||
|
|
||||||
if href and name and len(name) > 1: # Filter out empty or single char names
|
# Look specifically for .dropdown_menu classes
|
||||||
# Parse URL: /genre/house/5 -> slug='house', id='5'
|
dropdown_menus = container.find_all(class_='dropdown_menu')
|
||||||
url_parts = href.strip('/').split('/')
|
|
||||||
if len(url_parts) >= 3 and url_parts[0] == 'genre':
|
|
||||||
slug = url_parts[1]
|
|
||||||
genre_id = url_parts[2]
|
|
||||||
|
|
||||||
genres.append({
|
if not dropdown_menus:
|
||||||
'name': name,
|
# Fallback: Look for any element with class containing 'dropdown' and 'menu'
|
||||||
'slug': slug,
|
dropdown_menus = container.find_all(class_=re.compile(r'dropdown.*menu', re.I))
|
||||||
'id': genre_id,
|
|
||||||
'url': urljoin(self.base_url, href)
|
|
||||||
})
|
|
||||||
|
|
||||||
if genres:
|
if not dropdown_menus:
|
||||||
print(f"🎯 Successfully extracted {len(genres)} genres from dropdown")
|
print(f"⚠️ No .dropdown_menu found in container {container_idx + 1}")
|
||||||
break # Stop after first successful dropdown
|
continue
|
||||||
|
|
||||||
|
for menu_idx, menu in enumerate(dropdown_menus):
|
||||||
|
print(f"📋 Processing dropdown_menu {menu_idx + 1} in container {container_idx + 1}")
|
||||||
|
|
||||||
|
# Look for <li> elements first, then <a> elements within them
|
||||||
|
list_items = menu.find_all('li')
|
||||||
|
|
||||||
|
if list_items:
|
||||||
|
print(f"📝 Found {len(list_items)} list items in menu")
|
||||||
|
|
||||||
|
for li in list_items:
|
||||||
|
# Find anchor tag within the list item
|
||||||
|
link = li.find('a', href=re.compile(r'/genre/[^/]+/\d+'))
|
||||||
|
|
||||||
|
if link:
|
||||||
|
href = link.get('href', '')
|
||||||
|
name_text = link.get_text(strip=True)
|
||||||
|
|
||||||
|
# Keep the name as-is (don't remove "New" prefix)
|
||||||
|
name = name_text.strip()
|
||||||
|
|
||||||
|
# Filter out section titles and non-genre items
|
||||||
|
if href and name and len(name) > 1 and self._is_valid_genre_name(name):
|
||||||
|
# Parse URL: /genre/house/5 -> slug='house', id='5'
|
||||||
|
url_parts = href.strip('/').split('/')
|
||||||
|
if len(url_parts) >= 3 and url_parts[0] == 'genre':
|
||||||
|
slug = url_parts[1]
|
||||||
|
genre_id = url_parts[2]
|
||||||
|
|
||||||
|
genres.append({
|
||||||
|
'name': name,
|
||||||
|
'slug': slug,
|
||||||
|
'id': genre_id,
|
||||||
|
'url': urljoin(self.base_url, href)
|
||||||
|
})
|
||||||
|
print(f" ✅ Added: {name} ({slug}/{genre_id})")
|
||||||
|
else:
|
||||||
|
print(f" 🚫 Filtered out: '{name}' (appears to be a section title)")
|
||||||
|
else:
|
||||||
|
# Fallback: try the old method if no <li> elements found
|
||||||
|
print(f"⚠️ No <li> elements found, trying direct <a> search...")
|
||||||
|
genre_links = menu.find_all('a', href=re.compile(r'/genre/[^/]+/\d+'))
|
||||||
|
|
||||||
|
if genre_links:
|
||||||
|
print(f"🔗 Found {len(genre_links)} genre links in menu (fallback method)")
|
||||||
|
for link in genre_links:
|
||||||
|
href = link.get('href', '')
|
||||||
|
name_text = link.get_text(strip=True)
|
||||||
|
name = name_text.strip()
|
||||||
|
|
||||||
|
if href and name and len(name) > 1 and self._is_valid_genre_name(name):
|
||||||
|
url_parts = href.strip('/').split('/')
|
||||||
|
if len(url_parts) >= 3 and url_parts[0] == 'genre':
|
||||||
|
slug = url_parts[1]
|
||||||
|
genre_id = url_parts[2]
|
||||||
|
|
||||||
|
genres.append({
|
||||||
|
'name': name,
|
||||||
|
'slug': slug,
|
||||||
|
'id': genre_id,
|
||||||
|
'url': urljoin(self.base_url, href)
|
||||||
|
})
|
||||||
|
print(f" ✅ Added: {name} ({slug}/{genre_id})")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ No genre links found in dropdown_menu {menu_idx + 1}")
|
||||||
|
|
||||||
|
if genres:
|
||||||
|
print(f"🎯 Successfully extracted {len(genres)} genres from dropdown menu")
|
||||||
|
else:
|
||||||
|
print("⚠️ No genre links found in dropdown menu structure")
|
||||||
|
else:
|
||||||
|
print("❌ Could not find genres-dropdown-menu, trying fallback methods...")
|
||||||
|
|
||||||
|
# Fallback: Look for other potential dropdown structures
|
||||||
|
potential_dropdowns = [
|
||||||
|
soup.find('div', class_=re.compile(r'genres.*dropdown', re.I)),
|
||||||
|
soup.find('nav', class_=re.compile(r'genres', re.I)),
|
||||||
|
soup.find('div', class_=re.compile(r'dropdown.*genres', re.I)),
|
||||||
|
soup.find('ul', class_=re.compile(r'genres', re.I)),
|
||||||
|
soup.find('div', {'data-testid': 'genres-dropdown'}),
|
||||||
|
soup.find('div', {'aria-label': re.compile(r'genres', re.I)})
|
||||||
|
]
|
||||||
|
|
||||||
|
for dropdown in potential_dropdowns:
|
||||||
|
if dropdown:
|
||||||
|
print(f"✅ Found fallback dropdown: {dropdown.name} with class {dropdown.get('class')}")
|
||||||
|
genre_links = dropdown.find_all('a', href=re.compile(r'/genre/[^/]+/\d+'))
|
||||||
|
|
||||||
|
if genre_links:
|
||||||
|
print(f"🔗 Found {len(genre_links)} genre links in fallback dropdown")
|
||||||
|
for link in genre_links:
|
||||||
|
href = link.get('href', '')
|
||||||
|
name_text = link.get_text(strip=True)
|
||||||
|
name = re.sub(r'\s*New\s*', '', name_text).strip()
|
||||||
|
|
||||||
|
if href and name and len(name) > 1:
|
||||||
|
url_parts = href.strip('/').split('/')
|
||||||
|
if len(url_parts) >= 3 and url_parts[0] == 'genre':
|
||||||
|
slug = url_parts[1]
|
||||||
|
genre_id = url_parts[2]
|
||||||
|
|
||||||
|
genres.append({
|
||||||
|
'name': name,
|
||||||
|
'slug': slug,
|
||||||
|
'id': genre_id,
|
||||||
|
'url': urljoin(self.base_url, href)
|
||||||
|
})
|
||||||
|
|
||||||
|
if genres:
|
||||||
|
print(f"🎯 Successfully extracted {len(genres)} genres from fallback dropdown")
|
||||||
|
break
|
||||||
|
|
||||||
# Method 2: Look for any genre links on the page
|
# Method 2: Look for any genre links on the page
|
||||||
if not genres:
|
if not genres:
|
||||||
|
|
@ -405,7 +522,30 @@ class BeatportUnifiedScraper:
|
||||||
if not soup:
|
if not soup:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
# Look for hero release slideshow images
|
# Priority 1: Look for images in .artwork containers (new method)
|
||||||
|
artwork_imgs = soup.select('.artwork > img')
|
||||||
|
if artwork_imgs:
|
||||||
|
# First, try to find high-quality geo-media images in artwork containers
|
||||||
|
for img in artwork_imgs:
|
||||||
|
src = img.get('src', '')
|
||||||
|
if 'geo-media' in src and ('1050x508' in src or '500x500' in src):
|
||||||
|
print(f" ✅ Found high-quality artwork image: {src}")
|
||||||
|
return src
|
||||||
|
|
||||||
|
# Second, try any geo-media images in artwork containers
|
||||||
|
for img in artwork_imgs:
|
||||||
|
src = img.get('src', '')
|
||||||
|
if 'geo-media' in src:
|
||||||
|
print(f" ✅ Found geo-media artwork image: {src}")
|
||||||
|
return src
|
||||||
|
|
||||||
|
# Third, use any artwork image as fallback
|
||||||
|
first_artwork_src = artwork_imgs[0].get('src', '')
|
||||||
|
if first_artwork_src:
|
||||||
|
print(f" ✅ Found artwork image (fallback): {first_artwork_src}")
|
||||||
|
return first_artwork_src
|
||||||
|
|
||||||
|
# Priority 2: Original method - Look for hero release slideshow images
|
||||||
hero_images = soup.find_all('img', src=re.compile(r'geo-media\.beatport\.com/image_size/'))
|
hero_images = soup.find_all('img', src=re.compile(r'geo-media\.beatport\.com/image_size/'))
|
||||||
|
|
||||||
if hero_images:
|
if hero_images:
|
||||||
|
|
@ -413,11 +553,15 @@ class BeatportUnifiedScraper:
|
||||||
for img in hero_images:
|
for img in hero_images:
|
||||||
src = img.get('src', '')
|
src = img.get('src', '')
|
||||||
if '1050x508' in src or '500x500' in src:
|
if '1050x508' in src or '500x500' in src:
|
||||||
|
print(f" ✅ Found high-quality hero image: {src}")
|
||||||
return src
|
return src
|
||||||
|
|
||||||
# Fallback to any geo-media image
|
# Fallback to any geo-media image
|
||||||
return hero_images[0].get('src', '')
|
fallback_src = hero_images[0].get('src', '')
|
||||||
|
print(f" ✅ Found hero image (fallback): {fallback_src}")
|
||||||
|
return fallback_src
|
||||||
|
|
||||||
|
print(f" ⚠️ No suitable images found on page")
|
||||||
return None
|
return None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
|
||||||
|
|
@ -1933,6 +1933,37 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Genre Browser Modal -->
|
||||||
|
<div class="genre-browser-modal-overlay" id="genre-browser-modal">
|
||||||
|
<div class="genre-browser-modal-container">
|
||||||
|
<div class="genre-browser-modal-header">
|
||||||
|
<h2 class="genre-browser-modal-title">🎵 Browse by Genre</h2>
|
||||||
|
<button class="genre-browser-modal-close" id="genre-browser-modal-close">
|
||||||
|
<span class="genre-browser-close-icon">×</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="genre-browser-modal-content">
|
||||||
|
<div class="genre-browser-search-section">
|
||||||
|
<div class="genre-browser-search-container">
|
||||||
|
<input type="text" class="genre-browser-search-input" placeholder="Search genres..." id="genre-browser-search">
|
||||||
|
<span class="genre-browser-search-icon">🔍</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="genre-browser-genres-section">
|
||||||
|
<div class="genre-browser-genres-grid" id="genre-browser-genres-grid">
|
||||||
|
<!-- Loading placeholder -->
|
||||||
|
<div class="genre-browser-loading-container">
|
||||||
|
<div class="genre-browser-loading-spinner"></div>
|
||||||
|
<p class="genre-browser-loading-text">🔍 Discovering current Beatport genres...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
<script src="{{ url_for('static', filename='script.js') }}"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|
@ -20815,3 +20815,413 @@ async function handleHypeTop100Click() {
|
||||||
showToast(`Error loading Hype Top 100: ${error.message}`, 'error');
|
showToast(`Error loading Hype Top 100: ${error.message}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ================================= //
|
||||||
|
// GENRE BROWSER MODAL FUNCTIONS //
|
||||||
|
// ================================= //
|
||||||
|
|
||||||
|
// Cache for genre browser data to avoid re-loading
|
||||||
|
let genreBrowserCache = {
|
||||||
|
genres: null,
|
||||||
|
imagesLoaded: false,
|
||||||
|
lastLoaded: null,
|
||||||
|
imageLoadingActive: false,
|
||||||
|
imageWorkers: null
|
||||||
|
};
|
||||||
|
|
||||||
|
function initializeGenreBrowserModal() {
|
||||||
|
console.log('🎵 Initializing Genre Browser Modal...');
|
||||||
|
|
||||||
|
// Browse by Genre button click handler
|
||||||
|
const browseByGenreBtn = document.getElementById('browse-by-genre-btn');
|
||||||
|
if (browseByGenreBtn) {
|
||||||
|
browseByGenreBtn.addEventListener('click', () => {
|
||||||
|
console.log('🎵 Browse by Genre button clicked');
|
||||||
|
openGenreBrowserModal();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Modal close button handler
|
||||||
|
const modalCloseBtn = document.getElementById('genre-browser-modal-close');
|
||||||
|
if (modalCloseBtn) {
|
||||||
|
modalCloseBtn.addEventListener('click', closeGenreBrowserModal);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click outside modal to close
|
||||||
|
const modalOverlay = document.getElementById('genre-browser-modal');
|
||||||
|
if (modalOverlay) {
|
||||||
|
modalOverlay.addEventListener('click', (e) => {
|
||||||
|
if (e.target === modalOverlay) {
|
||||||
|
closeGenreBrowserModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Search functionality
|
||||||
|
const searchInput = document.getElementById('genre-browser-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.addEventListener('input', (e) => {
|
||||||
|
filterGenreBrowserCards(e.target.value);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ESC key to close modal
|
||||||
|
document.addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Escape' && isGenreBrowserModalOpen()) {
|
||||||
|
closeGenreBrowserModal();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log('✅ Genre Browser Modal initialized');
|
||||||
|
}
|
||||||
|
|
||||||
|
function openGenreBrowserModal() {
|
||||||
|
console.log('🎵 Opening Genre Browser Modal...');
|
||||||
|
|
||||||
|
const modal = document.getElementById('genre-browser-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.add('active');
|
||||||
|
document.body.style.overflow = 'hidden'; // Prevent background scrolling
|
||||||
|
|
||||||
|
// Check cache before loading genres
|
||||||
|
if (genreBrowserCache.genres && genreBrowserCache.genres.length > 0) {
|
||||||
|
console.log('💾 Using cached genres data');
|
||||||
|
displayCachedGenres();
|
||||||
|
} else {
|
||||||
|
console.log('🔄 No cached data, loading genres...');
|
||||||
|
loadGenreBrowserGenres();
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Genre Browser Modal opened');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeGenreBrowserModal() {
|
||||||
|
console.log('🎵 Closing Genre Browser Modal...');
|
||||||
|
|
||||||
|
const modal = document.getElementById('genre-browser-modal');
|
||||||
|
if (modal) {
|
||||||
|
modal.classList.remove('active');
|
||||||
|
document.body.style.overflow = ''; // Restore scrolling
|
||||||
|
|
||||||
|
// Clear search input but keep the genre data cached
|
||||||
|
const searchInput = document.getElementById('genre-browser-search');
|
||||||
|
if (searchInput) {
|
||||||
|
searchInput.value = '';
|
||||||
|
// Also reset the display filter to show all genres
|
||||||
|
filterGenreBrowserCards('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pause image loading workers if they're running
|
||||||
|
if (genreBrowserCache.imageLoadingActive) {
|
||||||
|
console.log('⏸️ Pausing image loading workers...');
|
||||||
|
genreBrowserCache.imageLoadingActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('✅ Genre Browser Modal closed (data preserved in cache)');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isGenreBrowserModalOpen() {
|
||||||
|
const modal = document.getElementById('genre-browser-modal');
|
||||||
|
return modal && modal.classList.contains('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGenreBrowserGenres() {
|
||||||
|
console.log('🔍 Loading genres for Genre Browser Modal...');
|
||||||
|
|
||||||
|
const genresGrid = document.getElementById('genre-browser-genres-grid');
|
||||||
|
if (!genresGrid) {
|
||||||
|
console.error('❌ Genre browser grid not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
genresGrid.innerHTML = `
|
||||||
|
<div class="genre-browser-loading-container">
|
||||||
|
<div class="genre-browser-loading-spinner"></div>
|
||||||
|
<p class="genre-browser-loading-text">🔍 Discovering current Beatport genres...</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// First, fetch genres quickly without images
|
||||||
|
console.log('🚀 Fetching genres without images for fast loading...');
|
||||||
|
const fastResponse = await fetch('/api/beatport/genres');
|
||||||
|
if (!fastResponse.ok) {
|
||||||
|
throw new Error(`API returned ${fastResponse.status}: ${fastResponse.statusText}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const fastData = await fastResponse.json();
|
||||||
|
const genres = fastData.genres || [];
|
||||||
|
|
||||||
|
if (genres.length === 0) {
|
||||||
|
genresGrid.innerHTML = `
|
||||||
|
<div class="genre-browser-loading-container">
|
||||||
|
<p style="color: rgba(255, 255, 255, 0.7);">⚠️ No genres available</p>
|
||||||
|
<button onclick="loadGenreBrowserGenres()" style="margin-top: 10px; padding: 10px 20px; border: 1px solid rgba(255, 255, 255, 0.3); background: rgba(20, 20, 20, 0.8); color: white; border-radius: 8px; cursor: pointer;">🔄 Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter out unwanted genres (section titles, etc.)
|
||||||
|
const filteredGenres = genres.filter(genre => {
|
||||||
|
const name = genre.name.toLowerCase().trim();
|
||||||
|
const unwantedGenres = [
|
||||||
|
'open format',
|
||||||
|
'electronic',
|
||||||
|
'genres',
|
||||||
|
'browse',
|
||||||
|
'charts',
|
||||||
|
'new releases',
|
||||||
|
'trending',
|
||||||
|
'featured',
|
||||||
|
'popular'
|
||||||
|
];
|
||||||
|
|
||||||
|
const isUnwanted = unwantedGenres.includes(name);
|
||||||
|
if (isUnwanted) {
|
||||||
|
console.log(`🚫 Filtered out unwanted genre: "${genre.name}"`);
|
||||||
|
}
|
||||||
|
return !isUnwanted;
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`📋 Filtered genres: ${genres.length} → ${filteredGenres.length} (removed ${genres.length - filteredGenres.length} unwanted)`);
|
||||||
|
|
||||||
|
// Generate genre cards dynamically (without images first)
|
||||||
|
const genreCardsHTML = filteredGenres.map(genre => `
|
||||||
|
<div class="genre-browser-card genre-browser-card-fallback"
|
||||||
|
data-genre-slug="${genre.slug}"
|
||||||
|
data-genre-id="${genre.id}"
|
||||||
|
data-genre-name="${genre.name}"
|
||||||
|
data-url="${genre.url}">
|
||||||
|
<div class="genre-browser-card-image">🎵</div>
|
||||||
|
<div class="genre-browser-card-content">
|
||||||
|
<h3 class="genre-browser-card-title">${genre.name}</h3>
|
||||||
|
<p class="genre-browser-card-subtitle">Top 10 & Top 100 Charts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
genresGrid.innerHTML = genreCardsHTML;
|
||||||
|
|
||||||
|
// Cache the filtered genres data
|
||||||
|
genreBrowserCache.genres = filteredGenres;
|
||||||
|
genreBrowserCache.lastLoaded = new Date();
|
||||||
|
genreBrowserCache.imagesLoaded = false;
|
||||||
|
|
||||||
|
console.log(`✅ Loaded ${filteredGenres.length} Beatport genres for modal (fast mode)`);
|
||||||
|
console.log(`💾 Cached ${filteredGenres.length} genres for future use`);
|
||||||
|
showToast(`Loaded ${filteredGenres.length} genres for browsing`, 'success');
|
||||||
|
|
||||||
|
// Now fetch images progressively in the background
|
||||||
|
if (filteredGenres.length > 5) {
|
||||||
|
console.log('🖼️ Loading genre images progressively for modal...');
|
||||||
|
loadGenreBrowserImagesProgressively(filteredGenres);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error loading genres for modal:', error);
|
||||||
|
genresGrid.innerHTML = `
|
||||||
|
<div class="genre-browser-loading-container">
|
||||||
|
<p style="color: rgba(255, 255, 255, 0.7);">❌ Failed to load genres: ${error.message}</p>
|
||||||
|
<button onclick="loadGenreBrowserGenres()" style="margin-top: 10px; padding: 10px 20px; border: 1px solid rgba(255, 255, 255, 0.3); background: rgba(20, 20, 20, 0.8); color: white; border-radius: 8px; cursor: pointer;">🔄 Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
showToast(`Error loading genres: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function displayCachedGenres() {
|
||||||
|
console.log('💾 Displaying cached genres...');
|
||||||
|
|
||||||
|
const genresGrid = document.getElementById('genre-browser-genres-grid');
|
||||||
|
if (!genresGrid) {
|
||||||
|
console.error('❌ Genre browser grid not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const genres = genreBrowserCache.genres;
|
||||||
|
if (!genres || genres.length === 0) {
|
||||||
|
console.error('❌ No cached genres available');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate genre cards from cached data
|
||||||
|
const genreCardsHTML = genres.map(genre => `
|
||||||
|
<div class="genre-browser-card genre-browser-card-fallback"
|
||||||
|
data-genre-slug="${genre.slug}"
|
||||||
|
data-genre-id="${genre.id}"
|
||||||
|
data-genre-name="${genre.name}"
|
||||||
|
data-url="${genre.url}">
|
||||||
|
<div class="genre-browser-card-image">🎵</div>
|
||||||
|
<div class="genre-browser-card-content">
|
||||||
|
<h3 class="genre-browser-card-title">${genre.name}</h3>
|
||||||
|
<p class="genre-browser-card-subtitle">Top 10 & Top 100 Charts</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
genresGrid.innerHTML = genreCardsHTML;
|
||||||
|
|
||||||
|
console.log(`✅ Displayed ${genres.length} cached genres instantly`);
|
||||||
|
|
||||||
|
// Handle image loading based on current state
|
||||||
|
if (genreBrowserCache.imagesLoaded) {
|
||||||
|
console.log('🖼️ Images already loaded, restoring them...');
|
||||||
|
restoreCachedImages(genres);
|
||||||
|
} else if (!genreBrowserCache.imageLoadingActive && genres.length > 5) {
|
||||||
|
// Resume or start image loading
|
||||||
|
const cachedCount = genres.filter(g => g.imageUrl).length;
|
||||||
|
if (cachedCount > 0) {
|
||||||
|
console.log(`🔄 Resuming image loading (${cachedCount}/${genres.length} already cached)...`);
|
||||||
|
restoreCachedImages(genres); // Show already cached images
|
||||||
|
} else {
|
||||||
|
console.log('🖼️ Starting fresh image loading for cached genres...');
|
||||||
|
}
|
||||||
|
loadGenreBrowserImagesProgressively(genres);
|
||||||
|
} else {
|
||||||
|
console.log('📷 Image loading in progress, showing cached images...');
|
||||||
|
restoreCachedImages(genres);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function restoreCachedImages(genres) {
|
||||||
|
// Restore images that were already loaded in previous sessions
|
||||||
|
genres.forEach(genre => {
|
||||||
|
if (genre.imageUrl) {
|
||||||
|
const genreCard = document.querySelector(
|
||||||
|
`.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (genreCard) {
|
||||||
|
const imageElement = genreCard.querySelector('.genre-browser-card-image');
|
||||||
|
if (imageElement) {
|
||||||
|
imageElement.innerHTML = `<img src="${genre.imageUrl}" alt="${genre.name}" style="width: 100%; height: 100%; object-fit: cover;">`;
|
||||||
|
genreCard.classList.remove('genre-browser-card-fallback');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGenreBrowserImagesProgressively(genres) {
|
||||||
|
// Load genre images with 2 concurrent workers for faster loading
|
||||||
|
// Only process genres that don't already have cached images
|
||||||
|
const imageQueue = genres.filter(genre => !genre.imageUrl);
|
||||||
|
let imagesLoaded = 0;
|
||||||
|
const maxWorkers = 2;
|
||||||
|
|
||||||
|
// Mark loading as active
|
||||||
|
genreBrowserCache.imageLoadingActive = true;
|
||||||
|
|
||||||
|
console.log(`🖼️ Starting progressive image loading for modal with ${maxWorkers} workers for ${imageQueue.length} remaining genres (${genres.length - imageQueue.length} already cached)`);
|
||||||
|
|
||||||
|
// If all images are already cached, mark as complete
|
||||||
|
if (imageQueue.length === 0) {
|
||||||
|
console.log('✅ All images already cached, marking as complete');
|
||||||
|
genreBrowserCache.imagesLoaded = true;
|
||||||
|
genreBrowserCache.imageLoadingActive = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Function to process a single image
|
||||||
|
async function processImage(genre) {
|
||||||
|
try {
|
||||||
|
// Fetch individual genre image from backend
|
||||||
|
const response = await fetch(`/api/beatport/genre-image/${genre.slug}/${genre.id}`);
|
||||||
|
|
||||||
|
if (response.ok) {
|
||||||
|
const data = await response.json();
|
||||||
|
|
||||||
|
if (data.success && data.image_url) {
|
||||||
|
// Cache the image URL in the genre object
|
||||||
|
genre.imageUrl = data.image_url;
|
||||||
|
|
||||||
|
// Find the genre card in the modal
|
||||||
|
const genreCard = document.querySelector(
|
||||||
|
`.genre-browser-card[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (genreCard) {
|
||||||
|
const imageElement = genreCard.querySelector('.genre-browser-card-image');
|
||||||
|
if (imageElement) {
|
||||||
|
// Replace the fallback emoji with the actual image
|
||||||
|
imageElement.innerHTML = `<img src="${data.image_url}" alt="${genre.name}" style="width: 100%; height: 100%; object-fit: cover;">`;
|
||||||
|
genreCard.classList.remove('genre-browser-card-fallback');
|
||||||
|
|
||||||
|
console.log(`✅ Loaded and cached image for ${genre.name} in modal`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
imagesLoaded++;
|
||||||
|
console.log(`📷 Progress: ${imagesLoaded}/${genres.length} images loaded for modal`);
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.log(`⚠️ Could not load image for ${genre.name} in modal: ${error.message}`);
|
||||||
|
imagesLoaded++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker function to process images from the queue
|
||||||
|
async function worker() {
|
||||||
|
while (imageQueue.length > 0 && genreBrowserCache.imageLoadingActive) {
|
||||||
|
const genre = imageQueue.shift();
|
||||||
|
if (genre) {
|
||||||
|
await processImage(genre);
|
||||||
|
// Small delay to prevent overwhelming the server
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we should pause
|
||||||
|
if (!genreBrowserCache.imageLoadingActive) {
|
||||||
|
console.log('⏸️ Worker paused - modal closed');
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the workers
|
||||||
|
const workers = [];
|
||||||
|
for (let i = 0; i < maxWorkers; i++) {
|
||||||
|
workers.push(worker());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all workers to complete
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
// Check if loading was completed or paused
|
||||||
|
if (genreBrowserCache.imageLoadingActive) {
|
||||||
|
// Completed successfully
|
||||||
|
genreBrowserCache.imagesLoaded = true;
|
||||||
|
genreBrowserCache.imageLoadingActive = false;
|
||||||
|
console.log(`🎉 Completed loading all genre images for modal (${imagesLoaded}/${genres.length})`);
|
||||||
|
console.log(`💾 Marked images as loaded in cache`);
|
||||||
|
} else {
|
||||||
|
// Was paused
|
||||||
|
console.log(`⏸️ Image loading paused (${imagesLoaded}/${genres.length} completed)`);
|
||||||
|
console.log(`💾 Partial progress saved in cache`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterGenreBrowserCards(searchTerm) {
|
||||||
|
const genreCards = document.querySelectorAll('.genre-browser-card');
|
||||||
|
const searchLower = searchTerm.toLowerCase();
|
||||||
|
|
||||||
|
genreCards.forEach(card => {
|
||||||
|
const genreName = card.dataset.genreName?.toLowerCase() || '';
|
||||||
|
const shouldShow = genreName.includes(searchLower);
|
||||||
|
|
||||||
|
card.style.display = shouldShow ? 'block' : 'none';
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`🔍 Filtered genre cards with search term: "${searchTerm}"`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the Genre Browser Modal when the page loads
|
||||||
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
|
initializeGenreBrowserModal();
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -14616,3 +14616,314 @@ body {
|
||||||
height: 18px;
|
height: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================= */
|
||||||
|
/* GENRE BROWSER MODAL STYLES */
|
||||||
|
/* ================================= */
|
||||||
|
|
||||||
|
.genre-browser-modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
background: rgba(0, 0, 0, 0.85);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
display: none;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
z-index: 9999;
|
||||||
|
animation: genreBrowserFadeIn 0.3s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-overlay.active {
|
||||||
|
display: flex;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-container {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(15, 15, 15, 0.95) 0%,
|
||||||
|
rgba(20, 20, 20, 0.95) 50%,
|
||||||
|
rgba(25, 25, 25, 0.95) 100%);
|
||||||
|
border-radius: 20px;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.8);
|
||||||
|
width: 95vw;
|
||||||
|
height: 90vh;
|
||||||
|
max-width: 1600px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
animation: genreBrowserSlideIn 0.4s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 25px 30px;
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
background: linear-gradient(90deg,
|
||||||
|
rgba(30, 30, 30, 0.8) 0%,
|
||||||
|
rgba(40, 40, 40, 0.8) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-close {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-close:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-close-icon {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #ffffff;
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-content {
|
||||||
|
flex: 1;
|
||||||
|
padding: 30px;
|
||||||
|
overflow-y: auto;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 25px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-search-section {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-search-container {
|
||||||
|
position: relative;
|
||||||
|
max-width: 500px;
|
||||||
|
margin: 0 auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 15px 20px 15px 50px;
|
||||||
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-radius: 25px;
|
||||||
|
background: rgba(20, 20, 20, 0.8);
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 16px;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-search-input:focus {
|
||||||
|
border-color: #1db954;
|
||||||
|
box-shadow: 0 0 20px rgba(29, 185, 84, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-search-input::placeholder {
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-search-icon {
|
||||||
|
position: absolute;
|
||||||
|
left: 18px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 18px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-genres-section {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-genres-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-loading-container {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 60px 20px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(255, 255, 255, 0.1);
|
||||||
|
border-top: 3px solid #1db954;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: genreBrowserSpin 1s linear infinite;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-loading-text {
|
||||||
|
font-size: 16px;
|
||||||
|
text-align: center;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(25, 25, 25, 0.9) 0%,
|
||||||
|
rgba(35, 35, 35, 0.9) 100%);
|
||||||
|
border-radius: 16px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
height: 200px;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: 0 15px 40px rgba(0, 0, 0, 0.6);
|
||||||
|
border-color: rgba(29, 185, 84, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 120px;
|
||||||
|
object-fit: cover;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card:hover .genre-browser-card-image {
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card-content {
|
||||||
|
padding: 20px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card-title {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.7);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card-subtitle {
|
||||||
|
font-size: 14px;
|
||||||
|
color: rgba(255, 255, 255, 0.7);
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card-fallback {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(30, 30, 30, 0.9) 0%,
|
||||||
|
rgba(45, 45, 45, 0.9) 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card-fallback .genre-browser-card-image {
|
||||||
|
background: linear-gradient(135deg,
|
||||||
|
rgba(40, 40, 40, 1) 0%,
|
||||||
|
rgba(60, 60, 60, 1) 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 48px;
|
||||||
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive Design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.genre-browser-modal-container {
|
||||||
|
width: 95vw;
|
||||||
|
height: 90vh;
|
||||||
|
border-radius: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-header {
|
||||||
|
padding: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-title {
|
||||||
|
font-size: 22px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-modal-content {
|
||||||
|
padding: 20px;
|
||||||
|
gap: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-genres-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(250px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card {
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.genre-browser-genres-grid {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-browser-card {
|
||||||
|
height: 160px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes genreBrowserFadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes genreBrowserSlideIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.9) translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1) translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes genreBrowserSpin {
|
||||||
|
0% {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue