pull genres with imges.
This commit is contained in:
parent
ccc686a892
commit
0ba4b6a079
6 changed files with 931 additions and 23 deletions
BIN
database/music_library.db-shm
Normal file
BIN
database/music_library.db-shm
Normal file
Binary file not shown.
0
database/music_library.db-wal
Normal file
0
database/music_library.db-wal
Normal file
156
web_server.py
156
web_server.py
|
|
@ -39,6 +39,7 @@ from services.sync_service import PlaylistSyncService
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
import yt_dlp
|
import yt_dlp
|
||||||
from core.matching_engine import MusicMatchingEngine
|
from core.matching_engine import MusicMatchingEngine
|
||||||
|
from beatport_unified_scraper import BeatportUnifiedScraper
|
||||||
|
|
||||||
# --- Flask App Setup ---
|
# --- Flask App Setup ---
|
||||||
base_dir = os.path.abspath(os.path.dirname(__file__))
|
base_dir = os.path.abspath(os.path.dirname(__file__))
|
||||||
|
|
@ -11855,6 +11856,161 @@ def get_active_media_server():
|
||||||
print(f"Error getting active media server: {e}")
|
print(f"Error getting active media server: {e}")
|
||||||
return jsonify({"success": False, "error": str(e)}), 500
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
# ================================= #
|
||||||
|
# BEATPORT API ENDPOINTS #
|
||||||
|
# ================================= #
|
||||||
|
|
||||||
|
@app.route('/api/beatport/genres', methods=['GET'])
|
||||||
|
def get_beatport_genres():
|
||||||
|
"""Get current Beatport genres with images dynamically scraped from homepage"""
|
||||||
|
try:
|
||||||
|
logger.info("🔍 API request for Beatport genres")
|
||||||
|
|
||||||
|
# Initialize the Beatport scraper
|
||||||
|
scraper = BeatportUnifiedScraper()
|
||||||
|
|
||||||
|
# Get query parameters
|
||||||
|
include_images = request.args.get('include_images', 'false').lower() == 'true'
|
||||||
|
|
||||||
|
# Discover genres dynamically
|
||||||
|
if include_images:
|
||||||
|
logger.info("🖼️ Including genre images in response (slower)")
|
||||||
|
genres = scraper.discover_genres_with_images(include_images=True)
|
||||||
|
else:
|
||||||
|
logger.info("📝 Returning genres without images (faster)")
|
||||||
|
genres = scraper.discover_genres_from_homepage()
|
||||||
|
|
||||||
|
logger.info(f"✅ Successfully discovered {len(genres)} Beatport genres")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"genres": genres,
|
||||||
|
"count": len(genres),
|
||||||
|
"includes_images": include_images
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching Beatport genres: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"genres": [],
|
||||||
|
"count": 0
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/beatport/genre/<genre_slug>/<genre_id>/tracks', methods=['GET'])
|
||||||
|
def get_beatport_genre_tracks(genre_slug, genre_id):
|
||||||
|
"""Get tracks for a specific Beatport genre"""
|
||||||
|
try:
|
||||||
|
logger.info(f"🎵 API request for {genre_slug} genre tracks (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 tracks for this genre
|
||||||
|
tracks = scraper.scrape_genre_charts(genre, limit=limit)
|
||||||
|
|
||||||
|
logger.info(f"✅ Successfully scraped {len(tracks)} tracks for {genre_slug}")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tracks": tracks,
|
||||||
|
"genre": genre,
|
||||||
|
"count": len(tracks)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching tracks for {genre_slug}: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"tracks": [],
|
||||||
|
"count": 0
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/beatport/top-100', methods=['GET'])
|
||||||
|
def get_beatport_top_100():
|
||||||
|
"""Get Beatport Top 100 tracks"""
|
||||||
|
try:
|
||||||
|
logger.info("🔥 API request for Beatport Top 100")
|
||||||
|
|
||||||
|
# Initialize the Beatport scraper
|
||||||
|
scraper = BeatportUnifiedScraper()
|
||||||
|
|
||||||
|
# Get query parameters
|
||||||
|
limit = int(request.args.get('limit', '100'))
|
||||||
|
|
||||||
|
# Scrape Top 100
|
||||||
|
tracks = scraper.scrape_top_100(limit=limit)
|
||||||
|
|
||||||
|
logger.info(f"✅ Successfully scraped {len(tracks)} tracks from Beatport Top 100")
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"tracks": tracks,
|
||||||
|
"chart_name": "Beatport Top 100",
|
||||||
|
"count": len(tracks)
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching Beatport Top 100: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"tracks": [],
|
||||||
|
"count": 0
|
||||||
|
}), 500
|
||||||
|
|
||||||
|
@app.route('/api/beatport/genre-image/<genre_slug>/<genre_id>', methods=['GET'])
|
||||||
|
def get_beatport_genre_image(genre_slug, genre_id):
|
||||||
|
"""Get image for a specific Beatport genre"""
|
||||||
|
try:
|
||||||
|
logger.info(f"🖼️ API request for {genre_slug} genre image")
|
||||||
|
|
||||||
|
# Initialize the Beatport scraper
|
||||||
|
scraper = BeatportUnifiedScraper()
|
||||||
|
|
||||||
|
# Construct genre URL
|
||||||
|
genre_url = f"{scraper.base_url}/genre/{genre_slug}/{genre_id}"
|
||||||
|
|
||||||
|
# Get genre image
|
||||||
|
image_url = scraper.get_genre_image(genre_url)
|
||||||
|
|
||||||
|
if image_url:
|
||||||
|
logger.info(f"✅ Found image for {genre_slug}")
|
||||||
|
return jsonify({
|
||||||
|
"success": True,
|
||||||
|
"image_url": image_url,
|
||||||
|
"genre_slug": genre_slug,
|
||||||
|
"genre_id": genre_id
|
||||||
|
})
|
||||||
|
else:
|
||||||
|
logger.info(f"⚠️ No image found for {genre_slug}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"image_url": None,
|
||||||
|
"genre_slug": genre_slug,
|
||||||
|
"genre_id": genre_id
|
||||||
|
})
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Error fetching image for {genre_slug}: {e}")
|
||||||
|
return jsonify({
|
||||||
|
"success": False,
|
||||||
|
"error": str(e),
|
||||||
|
"image_url": None
|
||||||
|
}), 500
|
||||||
|
|
||||||
class WebMetadataUpdateWorker:
|
class WebMetadataUpdateWorker:
|
||||||
"""Web-based metadata update worker - EXACT port of dashboard.py MetadataUpdateWorker"""
|
"""Web-based metadata update worker - EXACT port of dashboard.py MetadataUpdateWorker"""
|
||||||
|
|
||||||
|
|
|
||||||
193
webui/index.html
193
webui/index.html
|
|
@ -409,26 +409,183 @@
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="beatport-navigation">
|
<div class="beatport-navigation">
|
||||||
<div class="beatport-category-cards">
|
<!-- Main Category Cards View -->
|
||||||
<div class="beatport-category-card" data-category="top-charts">
|
<div class="beatport-main-view active" id="beatport-main-view">
|
||||||
<div class="category-icon top-charts-icon"></div>
|
<div class="beatport-category-cards">
|
||||||
<h3>🔥 Top Charts</h3>
|
<div class="beatport-category-card" data-category="top-charts">
|
||||||
<p>Beatport Top 100, Hype Top 10, New Releases</p>
|
<div class="category-icon top-charts-icon"></div>
|
||||||
<span class="category-count">3 Charts</span>
|
<h3>🔥 Top Charts</h3>
|
||||||
</div>
|
<p>Beatport Top 100, Hype Top 10, New Releases</p>
|
||||||
|
<span class="category-count">3 Charts</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="beatport-category-card" data-category="genres">
|
<div class="beatport-category-card" data-category="genres">
|
||||||
<div class="category-icon genres-icon"></div>
|
<div class="category-icon genres-icon"></div>
|
||||||
<h3>🎵 Genre Explorer</h3>
|
<h3>🎵 Genre Explorer</h3>
|
||||||
<p>House, Techno, Trance, and 36 more genres</p>
|
<p>House, Techno, Trance, and 36 more genres</p>
|
||||||
<span class="category-count">39 Genres</span>
|
<span class="category-count">39 Genres</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="beatport-category-card" data-category="staff-picks">
|
<div class="beatport-category-card" data-category="staff-picks">
|
||||||
<div class="category-icon staff-picks-icon"></div>
|
<div class="category-icon staff-picks-icon"></div>
|
||||||
<h3>📊 Staff Picks</h3>
|
<h3>📊 Staff Picks</h3>
|
||||||
<p>Curated selections and secret weapons</p>
|
<p>Curated selections and secret weapons</p>
|
||||||
<span class="category-count">5 Collections</span>
|
<span class="category-count">5 Collections</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Top Charts Sub-View -->
|
||||||
|
<div class="beatport-sub-view" id="beatport-top-charts-view">
|
||||||
|
<div class="beatport-breadcrumb">
|
||||||
|
<button class="breadcrumb-back">← Back to Categories</button>
|
||||||
|
<span class="breadcrumb-path">Browse Charts > Top Charts</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-list">
|
||||||
|
<div class="beatport-chart-item" data-chart-type="top-100" data-chart-id="top-100">
|
||||||
|
<div class="chart-icon">🏆</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>Beatport Top 100</h3>
|
||||||
|
<p>The hottest electronic tracks right now</p>
|
||||||
|
<span class="track-count">100 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-item" data-chart-type="hype" data-chart-id="hype-10">
|
||||||
|
<div class="chart-icon">🚀</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>Hype Top 10</h3>
|
||||||
|
<p>Rising stars and breakthrough tracks</p>
|
||||||
|
<span class="track-count">10 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-item" data-chart-type="new-releases" data-chart-id="new-releases">
|
||||||
|
<div class="chart-icon">✨</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>New Releases</h3>
|
||||||
|
<p>Fresh tracks added to Beatport</p>
|
||||||
|
<span class="track-count">50 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Genre Explorer Sub-View -->
|
||||||
|
<div class="beatport-sub-view" id="beatport-genres-view">
|
||||||
|
<div class="beatport-breadcrumb">
|
||||||
|
<button class="breadcrumb-back">← Back to Categories</button>
|
||||||
|
<span class="breadcrumb-path">Browse Charts > Genre Explorer</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-grid">
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="house" data-genre-id="5">
|
||||||
|
<div class="genre-icon">🏠</div>
|
||||||
|
<h3>House</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="tech-house" data-genre-id="11">
|
||||||
|
<div class="genre-icon">🔧</div>
|
||||||
|
<h3>Tech House</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="techno" data-genre-id="6">
|
||||||
|
<div class="genre-icon">⚡</div>
|
||||||
|
<h3>Techno</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="deep-house" data-genre-id="12">
|
||||||
|
<div class="genre-icon">🌊</div>
|
||||||
|
<h3>Deep House</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="trance" data-genre-id="7">
|
||||||
|
<div class="genre-icon">🌀</div>
|
||||||
|
<h3>Trance</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="drum-and-bass" data-genre-id="1">
|
||||||
|
<div class="genre-icon">🥁</div>
|
||||||
|
<h3>Drum & Bass</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="dubstep" data-genre-id="18">
|
||||||
|
<div class="genre-icon">🎵</div>
|
||||||
|
<h3>Dubstep</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="progressive-house" data-genre-id="15">
|
||||||
|
<div class="genre-icon">📈</div>
|
||||||
|
<h3>Progressive House</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="melodic-house-and-techno" data-genre-id="90">
|
||||||
|
<div class="genre-icon">🎼</div>
|
||||||
|
<h3>Melodic House & Techno</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="afro-house" data-genre-id="89">
|
||||||
|
<div class="genre-icon">🌍</div>
|
||||||
|
<h3>Afro House</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="minimal" data-genre-id="14">
|
||||||
|
<div class="genre-icon">⚫</div>
|
||||||
|
<h3>Minimal</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-genre-item" data-genre-slug="nu-disco" data-genre-id="50">
|
||||||
|
<div class="genre-icon">✨</div>
|
||||||
|
<h3>Nu Disco</h3>
|
||||||
|
<span class="genre-track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Staff Picks Sub-View -->
|
||||||
|
<div class="beatport-sub-view" id="beatport-staff-picks-view">
|
||||||
|
<div class="beatport-breadcrumb">
|
||||||
|
<button class="breadcrumb-back">← Back to Categories</button>
|
||||||
|
<span class="breadcrumb-path">Browse Charts > Staff Picks</span>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-list">
|
||||||
|
<div class="beatport-chart-item" data-chart-type="staff-pick" data-chart-id="best-new-tracks">
|
||||||
|
<div class="chart-icon">⭐</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>Best New Tracks</h3>
|
||||||
|
<p>Editor's picks of the week</p>
|
||||||
|
<span class="track-count">20 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-item" data-chart-type="staff-pick" data-chart-id="secret-weapons">
|
||||||
|
<div class="chart-icon">🔫</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>Secret Weapons</h3>
|
||||||
|
<p>Underground gems for DJs</p>
|
||||||
|
<span class="track-count">15 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-item" data-chart-type="staff-pick" data-chart-id="closing-essentials">
|
||||||
|
<div class="chart-icon">🌅</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>Closing Essentials</h3>
|
||||||
|
<p>Perfect tracks to end the night</p>
|
||||||
|
<span class="track-count">12 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-item" data-chart-type="staff-pick" data-chart-id="peak-time-driving">
|
||||||
|
<div class="chart-icon">🔥</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>Peak Time Driving</h3>
|
||||||
|
<p>High-energy dancefloor destroyers</p>
|
||||||
|
<span class="track-count">25 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="beatport-chart-item" data-chart-type="staff-pick" data-chart-id="underground-gems">
|
||||||
|
<div class="chart-icon">💎</div>
|
||||||
|
<div class="chart-info">
|
||||||
|
<h3>Underground Gems</h3>
|
||||||
|
<p>Hidden treasures from emerging artists</p>
|
||||||
|
<span class="track-count">18 tracks</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -9223,6 +9223,34 @@ function initializeSyncPage() {
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Logic for Beatport breadcrumb back buttons
|
||||||
|
const beatportBackButtons = document.querySelectorAll('.breadcrumb-back');
|
||||||
|
beatportBackButtons.forEach(button => {
|
||||||
|
button.addEventListener('click', () => {
|
||||||
|
showBeatportMainView();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logic for Beatport chart items
|
||||||
|
const beatportChartItems = document.querySelectorAll('.beatport-chart-item');
|
||||||
|
beatportChartItems.forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const chartType = item.dataset.chartType;
|
||||||
|
const chartId = item.dataset.chartId;
|
||||||
|
handleBeatportChartClick(chartType, chartId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Logic for Beatport genre items
|
||||||
|
const beatportGenreItems = document.querySelectorAll('.beatport-genre-item');
|
||||||
|
beatportGenreItems.forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const genreSlug = item.dataset.genreSlug;
|
||||||
|
const genreId = item.dataset.genreId;
|
||||||
|
handleBeatportGenreClick(genreSlug, genreId);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
// Logic for the Start Sync button
|
// Logic for the Start Sync button
|
||||||
const startSyncBtn = document.getElementById('start-sync-btn');
|
const startSyncBtn = document.getElementById('start-sync-btn');
|
||||||
if (startSyncBtn) {
|
if (startSyncBtn) {
|
||||||
|
|
@ -9541,22 +9569,236 @@ async function loadBeatportCharts() {
|
||||||
function handleBeatportCategoryClick(category) {
|
function handleBeatportCategoryClick(category) {
|
||||||
console.log(`🎵 Beatport category clicked: ${category}`);
|
console.log(`🎵 Beatport category clicked: ${category}`);
|
||||||
|
|
||||||
// Placeholder functionality for category navigation
|
// Show the appropriate sub-view based on category
|
||||||
switch(category) {
|
switch(category) {
|
||||||
case 'top-charts':
|
case 'top-charts':
|
||||||
showToast('🔥 Top Charts navigation coming soon!', 'info');
|
showBeatportSubView('top-charts');
|
||||||
break;
|
break;
|
||||||
case 'genres':
|
case 'genres':
|
||||||
showToast('🎵 Genre Explorer navigation coming soon!', 'info');
|
showBeatportSubView('genres');
|
||||||
|
loadBeatportGenres(); // Load genres dynamically
|
||||||
break;
|
break;
|
||||||
case 'staff-picks':
|
case 'staff-picks':
|
||||||
showToast('📊 Staff Picks navigation coming soon!', 'info');
|
showBeatportSubView('staff-picks');
|
||||||
break;
|
break;
|
||||||
default:
|
default:
|
||||||
showToast(`Category "${category}" clicked`, 'info');
|
showToast(`Unknown category: ${category}`, 'error');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function loadBeatportGenres() {
|
||||||
|
console.log('🔍 Loading Beatport genres dynamically...');
|
||||||
|
|
||||||
|
const genreGrid = document.querySelector('#beatport-genres-view .beatport-genre-grid');
|
||||||
|
if (!genreGrid) {
|
||||||
|
console.error('❌ Could not find genre grid element');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Show loading state
|
||||||
|
genreGrid.innerHTML = `
|
||||||
|
<div class="genre-loading-placeholder">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>🔍 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) {
|
||||||
|
genreGrid.innerHTML = `
|
||||||
|
<div class="genre-error-placeholder">
|
||||||
|
<p>⚠️ No genres available</p>
|
||||||
|
<button onclick="loadBeatportGenres()" class="refresh-genres-btn">🔄 Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate genre cards dynamically (without images first)
|
||||||
|
const genreCardsHTML = genres.map(genre => `
|
||||||
|
<div class="beatport-genre-item"
|
||||||
|
data-genre-slug="${genre.slug}"
|
||||||
|
data-genre-id="${genre.id}"
|
||||||
|
data-genre-name="${genre.name}">
|
||||||
|
<div class="genre-icon">🎵</div>
|
||||||
|
<h3>${genre.name}</h3>
|
||||||
|
<span class="track-count">Top 100</span>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
|
||||||
|
genreGrid.innerHTML = genreCardsHTML;
|
||||||
|
|
||||||
|
// Add click handlers to dynamically created genre items
|
||||||
|
const genreItems = genreGrid.querySelectorAll('.beatport-genre-item');
|
||||||
|
genreItems.forEach(item => {
|
||||||
|
item.addEventListener('click', () => {
|
||||||
|
const genreSlug = item.dataset.genreSlug;
|
||||||
|
const genreId = item.dataset.genreId;
|
||||||
|
const genreName = item.dataset.genreName;
|
||||||
|
handleBeatportGenreClick(genreSlug, genreId, genreName);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log(`✅ Loaded ${genres.length} Beatport genres dynamically (fast mode)`);
|
||||||
|
showToast(`Loaded ${genres.length} current Beatport genres`, 'success');
|
||||||
|
|
||||||
|
// Now fetch images progressively in the background if there are many genres
|
||||||
|
if (genres.length > 10) {
|
||||||
|
console.log('🖼️ Loading genre images progressively...');
|
||||||
|
loadGenreImagesProgressively(genres);
|
||||||
|
}
|
||||||
|
|
||||||
|
} catch (error) {
|
||||||
|
console.error('❌ Error loading Beatport genres:', error);
|
||||||
|
genreGrid.innerHTML = `
|
||||||
|
<div class="genre-error-placeholder">
|
||||||
|
<p>❌ Failed to load genres: ${error.message}</p>
|
||||||
|
<button onclick="loadBeatportGenres()" class="refresh-genres-btn">🔄 Retry</button>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
showToast(`Error loading Beatport genres: ${error.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGenreImagesProgressively(genres) {
|
||||||
|
// Load genre images with 2 concurrent workers for faster loading
|
||||||
|
|
||||||
|
const imageQueue = [...genres]; // Create a copy for processing
|
||||||
|
let imagesLoaded = 0;
|
||||||
|
const maxWorkers = 2;
|
||||||
|
|
||||||
|
console.log(`🖼️ Starting progressive image loading with ${maxWorkers} workers for ${imageQueue.length} genres`);
|
||||||
|
|
||||||
|
// 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) {
|
||||||
|
// Find the genre item in the DOM
|
||||||
|
const genreItem = document.querySelector(
|
||||||
|
`[data-genre-slug="${genre.slug}"][data-genre-id="${genre.id}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
if (genreItem) {
|
||||||
|
const iconElement = genreItem.querySelector('.genre-icon');
|
||||||
|
if (iconElement) {
|
||||||
|
// Create new image element with smooth transition
|
||||||
|
const imageDiv = document.createElement('div');
|
||||||
|
imageDiv.className = 'genre-image';
|
||||||
|
imageDiv.style.backgroundImage = `url('${data.image_url}')`;
|
||||||
|
imageDiv.style.opacity = '0';
|
||||||
|
imageDiv.style.transition = 'opacity 0.3s ease';
|
||||||
|
|
||||||
|
// Replace icon with image
|
||||||
|
iconElement.replaceWith(imageDiv);
|
||||||
|
|
||||||
|
// Trigger fade-in animation
|
||||||
|
setTimeout(() => {
|
||||||
|
imageDiv.style.opacity = '1';
|
||||||
|
}, 50);
|
||||||
|
|
||||||
|
imagesLoaded++;
|
||||||
|
console.log(`🖼️ [${imagesLoaded}/${imageQueue.length}] Loaded image for ${genre.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ Failed to load image for ${genre.name}:`, error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Worker function that processes images from the queue
|
||||||
|
async function imageWorker(workerId) {
|
||||||
|
while (imageQueue.length > 0) {
|
||||||
|
const genre = imageQueue.shift(); // Take next image from queue
|
||||||
|
if (genre) {
|
||||||
|
await processImage(genre);
|
||||||
|
|
||||||
|
// Small delay between requests to be respectful (500ms per worker = ~2 images per second total)
|
||||||
|
await new Promise(resolve => setTimeout(resolve, 500));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
console.log(`✅ Worker ${workerId} finished`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the workers
|
||||||
|
const workers = [];
|
||||||
|
for (let i = 0; i < maxWorkers; i++) {
|
||||||
|
workers.push(imageWorker(i + 1));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for all workers to complete
|
||||||
|
await Promise.all(workers);
|
||||||
|
|
||||||
|
console.log(`✅ Progressive image loading complete: ${imagesLoaded}/${genres.length} images loaded`);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBeatportSubView(viewType) {
|
||||||
|
// Hide main category view
|
||||||
|
const mainView = document.getElementById('beatport-main-view');
|
||||||
|
if (mainView) {
|
||||||
|
mainView.classList.remove('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hide all sub-views
|
||||||
|
document.querySelectorAll('.beatport-sub-view').forEach(view => {
|
||||||
|
view.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show the requested sub-view
|
||||||
|
const targetView = document.getElementById(`beatport-${viewType}-view`);
|
||||||
|
if (targetView) {
|
||||||
|
targetView.classList.add('active');
|
||||||
|
console.log(`🎵 Showing Beatport ${viewType} view`);
|
||||||
|
} else {
|
||||||
|
console.error(`🎵 Could not find view: beatport-${viewType}-view`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showBeatportMainView() {
|
||||||
|
// Hide all sub-views
|
||||||
|
document.querySelectorAll('.beatport-sub-view').forEach(view => {
|
||||||
|
view.classList.remove('active');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Show main category view
|
||||||
|
const mainView = document.getElementById('beatport-main-view');
|
||||||
|
if (mainView) {
|
||||||
|
mainView.classList.add('active');
|
||||||
|
console.log('🎵 Showing Beatport main view');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBeatportChartClick(chartType, chartId) {
|
||||||
|
console.log(`🎵 Beatport chart clicked: ${chartType} - ${chartId}`);
|
||||||
|
|
||||||
|
// Placeholder for Phase 2 - will open discovery modal
|
||||||
|
showToast(`🎵 Chart "${chartId}" selected - Discovery modal coming in Phase 2!`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleBeatportGenreClick(genreSlug, genreId) {
|
||||||
|
console.log(`🎵 Beatport genre clicked: ${genreSlug} (${genreId})`);
|
||||||
|
|
||||||
|
// Placeholder for Phase 2 - will open discovery modal
|
||||||
|
showToast(`🎵 Genre "${genreSlug}" selected - Discovery modal coming in Phase 2!`, 'info');
|
||||||
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// YOUTUBE PLAYLIST FUNCTIONALITY
|
// YOUTUBE PLAYLIST FUNCTIONALITY
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
|
||||||
|
|
@ -4542,6 +4542,359 @@ body {
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ================================= */
|
||||||
|
/* BEATPORT NAVIGATION VIEWS */
|
||||||
|
/* ================================= */
|
||||||
|
|
||||||
|
.beatport-main-view,
|
||||||
|
.beatport-sub-view {
|
||||||
|
display: none;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0 5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-main-view.active,
|
||||||
|
.beatport-sub-view.active {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================= */
|
||||||
|
/* BEATPORT BREADCRUMB NAVIGATION */
|
||||||
|
/* ================================= */
|
||||||
|
|
||||||
|
.beatport-breadcrumb {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 15px;
|
||||||
|
margin-bottom: 25px;
|
||||||
|
padding: 15px 20px;
|
||||||
|
background: rgba(25, 25, 25, 0.6);
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-back {
|
||||||
|
background: linear-gradient(135deg, #01FF95 0%, #00E085 100%);
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
color: #000;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 8px 16px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-back:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(1, 255, 149, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-path {
|
||||||
|
color: #888;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================= */
|
||||||
|
/* BEATPORT CHART LIST VIEW */
|
||||||
|
/* ================================= */
|
||||||
|
|
||||||
|
.beatport-chart-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-chart-item {
|
||||||
|
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.08);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 20px;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-chart-item::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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-chart-item:hover {
|
||||||
|
transform: translateY(-3px);
|
||||||
|
border-color: rgba(1, 255, 149, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 15px 30px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 20px rgba(1, 255, 149, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-chart-item:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-icon {
|
||||||
|
font-size: 32px;
|
||||||
|
width: 60px;
|
||||||
|
height: 60px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #01FF95 0%, #00E085 100%);
|
||||||
|
border-radius: 12px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-info {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-info h3 {
|
||||||
|
font-size: 18px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0 0 5px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-info p {
|
||||||
|
font-size: 14px;
|
||||||
|
color: #888;
|
||||||
|
margin: 0 0 8px 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.track-count {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(1, 255, 149, 0.15);
|
||||||
|
color: #01FF95;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-radius: 15px;
|
||||||
|
border: 1px solid rgba(1, 255, 149, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================= */
|
||||||
|
/* BEATPORT GENRE GRID VIEW */
|
||||||
|
/* ================================= */
|
||||||
|
|
||||||
|
.beatport-genre-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
|
||||||
|
gap: 20px;
|
||||||
|
padding: 10px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-genre-item {
|
||||||
|
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.08);
|
||||||
|
border-top: 1px solid rgba(255, 255, 255, 0.12);
|
||||||
|
padding: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
text-align: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
min-height: 120px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-genre-item::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;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-genre-item:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
border-color: rgba(1, 255, 149, 0.3);
|
||||||
|
box-shadow:
|
||||||
|
0 15px 30px rgba(0, 0, 0, 0.3),
|
||||||
|
0 0 20px rgba(1, 255, 149, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-genre-item:hover::before {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-icon {
|
||||||
|
font-size: 28px;
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: linear-gradient(135deg, #01FF95 0%, #00E085 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-genre-item h3 {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #ffffff;
|
||||||
|
margin: 0;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-track-count {
|
||||||
|
display: inline-block;
|
||||||
|
background: rgba(1, 255, 149, 0.15);
|
||||||
|
color: #01FF95;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
padding: 4px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
border: 1px solid rgba(1, 255, 149, 0.3);
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dynamic Genre Loading States */
|
||||||
|
.genre-loading-placeholder,
|
||||||
|
.genre-error-placeholder {
|
||||||
|
grid-column: 1 / -1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 40px 20px;
|
||||||
|
text-align: center;
|
||||||
|
color: #888;
|
||||||
|
}
|
||||||
|
|
||||||
|
.genre-loading-placeholder .loading-spinner {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
border: 3px solid rgba(1, 255, 149, 0.2);
|
||||||
|
border-top: 3px solid #01FF95;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-genres-btn {
|
||||||
|
background: linear-gradient(135deg, #01FF95 0%, #00E085 100%);
|
||||||
|
color: #000;
|
||||||
|
border: none;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-top: 15px;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.refresh-genres-btn:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 5px 15px rgba(1, 255, 149, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Genre Images */
|
||||||
|
.beatport-genre-item .genre-image {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border-radius: 12px;
|
||||||
|
background-size: cover;
|
||||||
|
background-position: center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
border: 2px solid rgba(1, 255, 149, 0.3);
|
||||||
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
position: relative;
|
||||||
|
z-index: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-genre-item:hover .genre-image {
|
||||||
|
transform: scale(1.1);
|
||||||
|
border-color: rgba(1, 255, 149, 0.6);
|
||||||
|
box-shadow:
|
||||||
|
0 6px 20px rgba(0, 0, 0, 0.4),
|
||||||
|
0 0 20px rgba(1, 255, 149, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ================================= */
|
||||||
|
/* RESPONSIVE DESIGN */
|
||||||
|
/* ================================= */
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.beatport-genre-grid {
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-chart-item {
|
||||||
|
flex-direction: column;
|
||||||
|
text-align: center;
|
||||||
|
gap: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-icon {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
font-size: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.beatport-breadcrumb {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.breadcrumb-back {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.playlist-scroll-container {
|
.playlist-scroll-container {
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue