Add comprehensive website functionality with enhanced navigation and sharing
- Create responsive HTML/CSS/JS website for Top 500 Albums - Add modern design with gradient background and glassmorphism effects - Implement search, filtering, and sorting functionality - Add reverse button for toggling sort order - Create bookmark system with jump-to-rank functionality - Add individual album share buttons with state preservation - Implement URL parameter handling for shareable links - Add favicon with vinyl record design - Create mobile-responsive layout with larger album covers - Add smooth animations and visual feedback throughout - Smart URL management: clean URLs for rank 1, parameterized for others 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
parent
97ea973de0
commit
25f453e3d9
8 changed files with 1649 additions and 0 deletions
157
create-favicon.html
Normal file
157
create-favicon.html
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<title>Create Favicon</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<canvas id="canvas" width="32" height="32" style="border: 1px solid #000; transform: scale(10);"></canvas>
|
||||||
|
<br><br>
|
||||||
|
<button onclick="downloadFavicon()">Download Favicon</button>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('canvas');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Create favicon design
|
||||||
|
function createFavicon() {
|
||||||
|
// Clear canvas
|
||||||
|
ctx.clearRect(0, 0, 32, 32);
|
||||||
|
|
||||||
|
// Background gradient (purple/blue theme)
|
||||||
|
const gradient = ctx.createLinearGradient(0, 0, 32, 32);
|
||||||
|
gradient.addColorStop(0, '#667eea');
|
||||||
|
gradient.addColorStop(1, '#764ba2');
|
||||||
|
|
||||||
|
// Fill background
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.fillRect(0, 0, 32, 32);
|
||||||
|
|
||||||
|
// Add vinyl record design
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(16, 16, 14, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Center hole
|
||||||
|
ctx.fillStyle = gradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(16, 16, 4, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Add grooves
|
||||||
|
ctx.strokeStyle = '#333';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
for (let r = 6; r <= 12; r += 2) {
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(16, 16, r, 0, 2 * Math.PI);
|
||||||
|
ctx.stroke();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add number "500" in small text
|
||||||
|
ctx.fillStyle = 'white';
|
||||||
|
ctx.font = 'bold 6px Arial';
|
||||||
|
ctx.textAlign = 'center';
|
||||||
|
ctx.fillText('500', 16, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
function downloadFavicon() {
|
||||||
|
// Create multiple sizes for ICO file
|
||||||
|
const sizes = [16, 32, 48];
|
||||||
|
const images = [];
|
||||||
|
|
||||||
|
sizes.forEach(size => {
|
||||||
|
const tempCanvas = document.createElement('canvas');
|
||||||
|
tempCanvas.width = size;
|
||||||
|
tempCanvas.height = size;
|
||||||
|
const tempCtx = tempCanvas.getContext('2d');
|
||||||
|
|
||||||
|
// Scale the design
|
||||||
|
const scale = size / 32;
|
||||||
|
tempCtx.scale(scale, scale);
|
||||||
|
|
||||||
|
// Redraw for this size
|
||||||
|
// Background gradient
|
||||||
|
const gradient = tempCtx.createLinearGradient(0, 0, 32, 32);
|
||||||
|
gradient.addColorStop(0, '#667eea');
|
||||||
|
gradient.addColorStop(1, '#764ba2');
|
||||||
|
|
||||||
|
tempCtx.fillStyle = gradient;
|
||||||
|
tempCtx.fillRect(0, 0, 32, 32);
|
||||||
|
|
||||||
|
// Vinyl record
|
||||||
|
tempCtx.fillStyle = '#1a1a1a';
|
||||||
|
tempCtx.beginPath();
|
||||||
|
tempCtx.arc(16, 16, 14, 0, 2 * Math.PI);
|
||||||
|
tempCtx.fill();
|
||||||
|
|
||||||
|
// Center hole
|
||||||
|
tempCtx.fillStyle = gradient;
|
||||||
|
tempCtx.beginPath();
|
||||||
|
tempCtx.arc(16, 16, 4, 0, 2 * Math.PI);
|
||||||
|
tempCtx.fill();
|
||||||
|
|
||||||
|
// Grooves
|
||||||
|
if (size >= 24) {
|
||||||
|
tempCtx.strokeStyle = '#333';
|
||||||
|
tempCtx.lineWidth = 1;
|
||||||
|
for (let r = 6; r <= 12; r += 2) {
|
||||||
|
tempCtx.beginPath();
|
||||||
|
tempCtx.arc(16, 16, r, 0, 2 * Math.PI);
|
||||||
|
tempCtx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// "500" text (only for larger sizes)
|
||||||
|
if (size >= 24) {
|
||||||
|
tempCtx.fillStyle = 'white';
|
||||||
|
tempCtx.font = 'bold 6px Arial';
|
||||||
|
tempCtx.textAlign = 'center';
|
||||||
|
tempCtx.fillText('500', 16, 28);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert to blob
|
||||||
|
const imageData = tempCtx.getImageData(0, 0, size, size);
|
||||||
|
images.push({size, imageData});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Download as PNG (browsers don't support ICO creation directly)
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'favicon-32x32.png';
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
link.click();
|
||||||
|
|
||||||
|
// Also create 16x16 version
|
||||||
|
const canvas16 = document.createElement('canvas');
|
||||||
|
canvas16.width = 16;
|
||||||
|
canvas16.height = 16;
|
||||||
|
const ctx16 = canvas16.getContext('2d');
|
||||||
|
ctx16.scale(0.5, 0.5);
|
||||||
|
|
||||||
|
const gradient16 = ctx16.createLinearGradient(0, 0, 32, 32);
|
||||||
|
gradient16.addColorStop(0, '#667eea');
|
||||||
|
gradient16.addColorStop(1, '#764ba2');
|
||||||
|
|
||||||
|
ctx16.fillStyle = gradient16;
|
||||||
|
ctx16.fillRect(0, 0, 32, 32);
|
||||||
|
|
||||||
|
ctx16.fillStyle = '#1a1a1a';
|
||||||
|
ctx16.beginPath();
|
||||||
|
ctx16.arc(16, 16, 14, 0, 2 * Math.PI);
|
||||||
|
ctx16.fill();
|
||||||
|
|
||||||
|
ctx16.fillStyle = gradient16;
|
||||||
|
ctx16.beginPath();
|
||||||
|
ctx16.arc(16, 16, 4, 0, 2 * Math.PI);
|
||||||
|
ctx16.fill();
|
||||||
|
|
||||||
|
const link16 = document.createElement('a');
|
||||||
|
link16.download = 'favicon-16x16.png';
|
||||||
|
link16.href = canvas16.toDataURL();
|
||||||
|
link16.click();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create the favicon on load
|
||||||
|
createFavicon();
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
77
create_favicon.py
Normal file
77
create_favicon.py
Normal file
|
|
@ -0,0 +1,77 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Create a favicon for the Top 500 Albums website
|
||||||
|
"""
|
||||||
|
|
||||||
|
from PIL import Image, ImageDraw, ImageFont
|
||||||
|
import os
|
||||||
|
|
||||||
|
def create_favicon():
|
||||||
|
# Create a new image with transparent background
|
||||||
|
size = 32
|
||||||
|
img = Image.new('RGBA', (size, size), (0, 0, 0, 0))
|
||||||
|
draw = ImageDraw.Draw(img)
|
||||||
|
|
||||||
|
# Create gradient background (simulate with filled circle)
|
||||||
|
# Purple/blue gradient colors
|
||||||
|
colors = [(102, 126, 234), (118, 75, 162)] # #667eea to #764ba2
|
||||||
|
|
||||||
|
# Draw background circle
|
||||||
|
margin = 1
|
||||||
|
draw.ellipse([margin, margin, size-margin, size-margin],
|
||||||
|
fill=(102, 126, 234), outline=None)
|
||||||
|
|
||||||
|
# Draw vinyl record
|
||||||
|
record_margin = 4
|
||||||
|
draw.ellipse([record_margin, record_margin, size-record_margin, size-record_margin],
|
||||||
|
fill=(26, 26, 26), outline=None)
|
||||||
|
|
||||||
|
# Draw center hole
|
||||||
|
center = size // 2
|
||||||
|
hole_radius = 3
|
||||||
|
draw.ellipse([center-hole_radius, center-hole_radius,
|
||||||
|
center+hole_radius, center+hole_radius],
|
||||||
|
fill=(102, 126, 234), outline=None)
|
||||||
|
|
||||||
|
# Draw grooves
|
||||||
|
for r in range(6, 12, 2):
|
||||||
|
draw.ellipse([center-r, center-r, center+r, center+r],
|
||||||
|
fill=None, outline=(51, 51, 51), width=1)
|
||||||
|
|
||||||
|
# Try to add "500" text
|
||||||
|
try:
|
||||||
|
# Use default font, small size
|
||||||
|
font = ImageFont.load_default()
|
||||||
|
text = "500"
|
||||||
|
|
||||||
|
# Get text size
|
||||||
|
bbox = draw.textbbox((0, 0), text, font=font)
|
||||||
|
text_width = bbox[2] - bbox[0]
|
||||||
|
text_height = bbox[3] - bbox[1]
|
||||||
|
|
||||||
|
# Center text at bottom
|
||||||
|
x = (size - text_width) // 2
|
||||||
|
y = size - text_height - 2
|
||||||
|
|
||||||
|
draw.text((x, y), text, fill=(255, 255, 255), font=font)
|
||||||
|
except:
|
||||||
|
# If font fails, just add a music note
|
||||||
|
draw.text((center-4, size-10), "♫", fill=(255, 255, 255))
|
||||||
|
|
||||||
|
# Save as PNG first
|
||||||
|
img.save('favicon-32x32.png', 'PNG')
|
||||||
|
|
||||||
|
# Create 16x16 version
|
||||||
|
img16 = img.resize((16, 16), Image.Resampling.LANCZOS)
|
||||||
|
img16.save('favicon-16x16.png', 'PNG')
|
||||||
|
|
||||||
|
# Create ICO file with multiple sizes
|
||||||
|
img.save('favicon.ico', format='ICO', sizes=[(16, 16), (32, 32)])
|
||||||
|
|
||||||
|
print("Favicon files created:")
|
||||||
|
print("- favicon.ico")
|
||||||
|
print("- favicon-16x16.png")
|
||||||
|
print("- favicon-32x32.png")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_favicon()
|
||||||
59
create_simple_favicon.py
Normal file
59
create_simple_favicon.py
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Create a simple favicon using basic drawing
|
||||||
|
"""
|
||||||
|
|
||||||
|
def create_favicon_data():
|
||||||
|
"""Create a simple 16x16 favicon as a data URL"""
|
||||||
|
# Create a simple pattern for 16x16 favicon
|
||||||
|
# This will be a simplified version of our design
|
||||||
|
|
||||||
|
favicon_html = '''
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Favicon Creator</title></head>
|
||||||
|
<body>
|
||||||
|
<canvas id="favicon" width="16" height="16"></canvas>
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('favicon');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Fill background with gradient-like color
|
||||||
|
ctx.fillStyle = '#667eea';
|
||||||
|
ctx.fillRect(0, 0, 16, 16);
|
||||||
|
|
||||||
|
// Draw record
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(8, 8, 6, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Center hole
|
||||||
|
ctx.fillStyle = '#667eea';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(8, 8, 2, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Download as PNG
|
||||||
|
function download() {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'favicon-16x16.png';
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto download
|
||||||
|
setTimeout(download, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
|
|
||||||
|
with open('favicon_generator.html', 'w') as f:
|
||||||
|
f.write(favicon_html)
|
||||||
|
|
||||||
|
print("Created favicon_generator.html - open this in a browser to download the favicon")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
create_favicon_data()
|
||||||
24
favicon.svg
Normal file
24
favicon.svg
Normal file
|
|
@ -0,0 +1,24 @@
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
|
||||||
|
<defs>
|
||||||
|
<linearGradient id="grad1" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||||
|
<stop offset="0%" style="stop-color:#667eea;stop-opacity:1" />
|
||||||
|
<stop offset="100%" style="stop-color:#764ba2;stop-opacity:1" />
|
||||||
|
</linearGradient>
|
||||||
|
</defs>
|
||||||
|
|
||||||
|
<!-- Background circle -->
|
||||||
|
<circle cx="16" cy="16" r="15" fill="url(#grad1)" />
|
||||||
|
|
||||||
|
<!-- Vinyl record -->
|
||||||
|
<circle cx="16" cy="16" r="12" fill="#1a1a1a" />
|
||||||
|
|
||||||
|
<!-- Record grooves -->
|
||||||
|
<circle cx="16" cy="16" r="9" fill="none" stroke="#333" stroke-width="0.5" />
|
||||||
|
<circle cx="16" cy="16" r="7" fill="none" stroke="#333" stroke-width="0.5" />
|
||||||
|
|
||||||
|
<!-- Center hole -->
|
||||||
|
<circle cx="16" cy="16" r="3" fill="url(#grad1)" />
|
||||||
|
|
||||||
|
<!-- "500" text -->
|
||||||
|
<text x="16" y="28" font-family="Arial, sans-serif" font-size="5" font-weight="bold" text-anchor="middle" fill="white">500</text>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 930 B |
41
favicon_generator.html
Normal file
41
favicon_generator.html
Normal file
|
|
@ -0,0 +1,41 @@
|
||||||
|
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><title>Favicon Creator</title></head>
|
||||||
|
<body>
|
||||||
|
<canvas id="favicon" width="16" height="16"></canvas>
|
||||||
|
<script>
|
||||||
|
const canvas = document.getElementById('favicon');
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
|
||||||
|
// Fill background with gradient-like color
|
||||||
|
ctx.fillStyle = '#667eea';
|
||||||
|
ctx.fillRect(0, 0, 16, 16);
|
||||||
|
|
||||||
|
// Draw record
|
||||||
|
ctx.fillStyle = '#1a1a1a';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(8, 8, 6, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Center hole
|
||||||
|
ctx.fillStyle = '#667eea';
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(8, 8, 2, 0, 2 * Math.PI);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Download as PNG
|
||||||
|
function download() {
|
||||||
|
const link = document.createElement('a');
|
||||||
|
link.download = 'favicon-16x16.png';
|
||||||
|
link.href = canvas.toDataURL();
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto download
|
||||||
|
setTimeout(download, 100);
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
100
index.html
Normal file
100
index.html
Normal file
|
|
@ -0,0 +1,100 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Top 500 Albums of All Time</title>
|
||||||
|
<link rel="icon" type="image/svg+xml" href="favicon.svg">
|
||||||
|
<link rel="icon" type="image/png" sizes="32x32" href="favicon-32x32.png">
|
||||||
|
<link rel="icon" type="image/png" sizes="16x16" href="favicon-16x16.png">
|
||||||
|
<link rel="stylesheet" href="styles.css">
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<header class="header">
|
||||||
|
<div class="container">
|
||||||
|
<h1 class="title">Top 500 Albums of All Time</h1>
|
||||||
|
<p class="subtitle">The Greatest Albums Ever Made - 2023 Edition</p>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="main">
|
||||||
|
<div class="container">
|
||||||
|
<div class="controls">
|
||||||
|
<div class="search-container">
|
||||||
|
<input type="text" id="searchInput" placeholder="Search albums, artists..." class="search-input">
|
||||||
|
<button id="searchButton" class="search-button">🔍</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="filters">
|
||||||
|
<select id="statusFilter" class="filter-select">
|
||||||
|
<option value="">All Albums</option>
|
||||||
|
<option value="New in 2023">New in 2023</option>
|
||||||
|
<option value="improved">Improved Ranking</option>
|
||||||
|
<option value="dropped">Dropped Ranking</option>
|
||||||
|
<option value="No change">No Change</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<select id="sortBy" class="filter-select">
|
||||||
|
<option value="rank">Sort by Rank</option>
|
||||||
|
<option value="artist">Sort by Artist</option>
|
||||||
|
<option value="album">Sort by Album</option>
|
||||||
|
<option value="year">Sort by Year</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<button id="reverseButton" class="reverse-button" title="Reverse order">
|
||||||
|
<span id="reverseIcon">↕️</span>
|
||||||
|
<span id="reverseText">Reverse</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bookmark-controls">
|
||||||
|
<div class="jump-to-rank">
|
||||||
|
<input type="number" id="jumpToRank" placeholder="Jump to rank (default: 1)..." min="1" max="500" class="rank-input">
|
||||||
|
<button id="jumpButton" class="jump-button" title="Jump to rank">Go</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="stats" id="stats">
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" id="totalAlbums">500</span>
|
||||||
|
<span class="stat-label">Total Albums</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" id="newAlbums">192</span>
|
||||||
|
<span class="stat-label">New in 2023</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" id="improvedAlbums">164</span>
|
||||||
|
<span class="stat-label">Improved</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-item">
|
||||||
|
<span class="stat-number" id="droppedAlbums">113</span>
|
||||||
|
<span class="stat-label">Dropped</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="albums-grid" id="albumsGrid">
|
||||||
|
<!-- Albums will be loaded here by JavaScript -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="loading" id="loading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Loading albums...</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="error" id="error" style="display: none;">
|
||||||
|
<p>Error loading albums. Please try again later.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<footer class="footer">
|
||||||
|
<div class="container">
|
||||||
|
<p>© 2023 Top 500 Albums. Data sourced from Rolling Stone and Wikipedia.</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
|
||||||
|
<script src="script.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
635
script.js
Normal file
635
script.js
Normal file
|
|
@ -0,0 +1,635 @@
|
||||||
|
// Global variables
|
||||||
|
let albumsData = [];
|
||||||
|
let filteredData = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
const itemsPerPage = 50;
|
||||||
|
let isReversed = false;
|
||||||
|
|
||||||
|
// DOM elements
|
||||||
|
const albumsGrid = document.getElementById('albumsGrid');
|
||||||
|
const searchInput = document.getElementById('searchInput');
|
||||||
|
const statusFilter = document.getElementById('statusFilter');
|
||||||
|
const sortBy = document.getElementById('sortBy');
|
||||||
|
const reverseButton = document.getElementById('reverseButton');
|
||||||
|
const jumpToRank = document.getElementById('jumpToRank');
|
||||||
|
const jumpButton = document.getElementById('jumpButton');
|
||||||
|
const loading = document.getElementById('loading');
|
||||||
|
const error = document.getElementById('error');
|
||||||
|
const stats = document.getElementById('stats');
|
||||||
|
|
||||||
|
// Initialize the application
|
||||||
|
document.addEventListener('DOMContentLoaded', function() {
|
||||||
|
loadAlbumsData();
|
||||||
|
setupEventListeners();
|
||||||
|
handleUrlParams();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Setup event listeners
|
||||||
|
function setupEventListeners() {
|
||||||
|
searchInput.addEventListener('input', debounce(handleSearch, 300));
|
||||||
|
statusFilter.addEventListener('change', handleFilter);
|
||||||
|
sortBy.addEventListener('change', handleSort);
|
||||||
|
reverseButton.addEventListener('click', handleReverse);
|
||||||
|
jumpButton.addEventListener('click', handleJumpToRank);
|
||||||
|
jumpToRank.addEventListener('keypress', handleRankKeypress);
|
||||||
|
|
||||||
|
// Add scroll listener for infinite scroll
|
||||||
|
window.addEventListener('scroll', handleScroll);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load albums data from CSV
|
||||||
|
async function loadAlbumsData() {
|
||||||
|
try {
|
||||||
|
const response = await fetch('top_500_albums_2023.csv');
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error('Failed to load data');
|
||||||
|
}
|
||||||
|
|
||||||
|
const csvText = await response.text();
|
||||||
|
albumsData = parseCSV(csvText);
|
||||||
|
filteredData = [...albumsData];
|
||||||
|
|
||||||
|
hideLoading();
|
||||||
|
renderAlbums();
|
||||||
|
updateStats();
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading data:', err);
|
||||||
|
showError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse CSV data
|
||||||
|
function parseCSV(csvText) {
|
||||||
|
const lines = csvText.split('\n');
|
||||||
|
const headers = lines[0].split(',').map(h => h.trim().replace(/"/g, ''));
|
||||||
|
const albums = [];
|
||||||
|
|
||||||
|
for (let i = 1; i < lines.length; i++) {
|
||||||
|
const line = lines[i].trim();
|
||||||
|
if (!line) continue;
|
||||||
|
|
||||||
|
// Handle CSV parsing with proper quote handling
|
||||||
|
const values = parseCSVLine(line);
|
||||||
|
if (values.length < headers.length) continue;
|
||||||
|
|
||||||
|
const album = {};
|
||||||
|
headers.forEach((header, index) => {
|
||||||
|
album[header] = values[index] ? values[index].replace(/"/g, '').trim() : '';
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert rank to number
|
||||||
|
album.Rank = parseInt(album.Rank) || 0;
|
||||||
|
|
||||||
|
// Extract year from Info field
|
||||||
|
const yearMatch = album.Info ? album.Info.match(/\b(19|20)\d{2}\b/) : null;
|
||||||
|
album.Year = yearMatch ? parseInt(yearMatch[0]) : null;
|
||||||
|
|
||||||
|
albums.push(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
return albums;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse a single CSV line handling quotes and commas
|
||||||
|
function parseCSVLine(line) {
|
||||||
|
const values = [];
|
||||||
|
let current = '';
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < line.length; i++) {
|
||||||
|
const char = line[i];
|
||||||
|
|
||||||
|
if (char === '"') {
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
} else if (char === ',' && !inQuotes) {
|
||||||
|
values.push(current);
|
||||||
|
current = '';
|
||||||
|
} else {
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
values.push(current);
|
||||||
|
return values;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Render albums
|
||||||
|
function renderAlbums() {
|
||||||
|
const startIndex = (currentPage - 1) * itemsPerPage;
|
||||||
|
const endIndex = startIndex + itemsPerPage;
|
||||||
|
const albumsToShow = filteredData.slice(0, endIndex);
|
||||||
|
|
||||||
|
if (currentPage === 1) {
|
||||||
|
albumsGrid.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
albumsToShow.slice(startIndex).forEach(album => {
|
||||||
|
const albumCard = createAlbumCard(album);
|
||||||
|
albumsGrid.appendChild(albumCard);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create album card element
|
||||||
|
function createAlbumCard(album) {
|
||||||
|
const card = document.createElement('div');
|
||||||
|
card.className = 'album-card';
|
||||||
|
card.setAttribute('data-rank', album.Rank);
|
||||||
|
|
||||||
|
const statusClass = getStatusClass(album.Status);
|
||||||
|
const statusText = formatStatus(album.Status);
|
||||||
|
|
||||||
|
card.innerHTML = `
|
||||||
|
<div class="album-rank">#${album.Rank}</div>
|
||||||
|
<div class="album-content">
|
||||||
|
<div class="album-title">${escapeHtml(album.Album)}</div>
|
||||||
|
<div class="album-artist">${escapeHtml(album.Artist)}</div>
|
||||||
|
${album.Info ? `<div class="album-info">${escapeHtml(album.Info)}</div>` : ''}
|
||||||
|
<div class="album-status ${statusClass}">${statusText}</div>
|
||||||
|
${album.Description ? `<div class="album-description">${escapeHtml(album.Description)}</div>` : ''}
|
||||||
|
</div>
|
||||||
|
<div class="album-cover">
|
||||||
|
<div class="album-cover-icon">🎵</div>
|
||||||
|
</div>
|
||||||
|
<button class="album-share" title="Share this album" data-rank="${album.Rank}">🔗</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
// Add click handler to expand description
|
||||||
|
const description = card.querySelector('.album-description');
|
||||||
|
if (description) {
|
||||||
|
description.addEventListener('click', function() {
|
||||||
|
this.classList.toggle('expanded');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add click handler for share button
|
||||||
|
const shareBtn = card.querySelector('.album-share');
|
||||||
|
shareBtn.addEventListener('click', function(e) {
|
||||||
|
e.stopPropagation(); // Prevent card click
|
||||||
|
const rank = this.getAttribute('data-rank');
|
||||||
|
handleAlbumShare(rank, this);
|
||||||
|
});
|
||||||
|
|
||||||
|
return card;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get CSS class for status
|
||||||
|
function getStatusClass(status) {
|
||||||
|
if (status === 'New in 2023') return 'status-new';
|
||||||
|
if (status === 'No change') return 'status-no-change';
|
||||||
|
if (status.startsWith('+')) return 'status-improved';
|
||||||
|
if (status.startsWith('-')) return 'status-dropped';
|
||||||
|
return 'status-no-change';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format status text
|
||||||
|
function formatStatus(status) {
|
||||||
|
if (status === 'New in 2023') return 'New in 2023';
|
||||||
|
if (status === 'No change') return 'No Change';
|
||||||
|
if (status.startsWith('+')) return `↗ ${status}`;
|
||||||
|
if (status.startsWith('-')) return `↘ ${status}`;
|
||||||
|
return status;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle search
|
||||||
|
function handleSearch(event) {
|
||||||
|
const query = event.target.value.toLowerCase().trim();
|
||||||
|
|
||||||
|
if (!query) {
|
||||||
|
filteredData = [...albumsData];
|
||||||
|
} else {
|
||||||
|
filteredData = albumsData.filter(album =>
|
||||||
|
album.Artist.toLowerCase().includes(query) ||
|
||||||
|
album.Album.toLowerCase().includes(query) ||
|
||||||
|
(album.Description && album.Description.toLowerCase().includes(query))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset reverse state when searching
|
||||||
|
resetReverse();
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
renderAlbums();
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle filter
|
||||||
|
function handleFilter(event) {
|
||||||
|
const filterValue = event.target.value;
|
||||||
|
|
||||||
|
if (!filterValue) {
|
||||||
|
filteredData = [...albumsData];
|
||||||
|
} else if (filterValue === 'improved') {
|
||||||
|
filteredData = albumsData.filter(album => album.Status.startsWith('+'));
|
||||||
|
} else if (filterValue === 'dropped') {
|
||||||
|
filteredData = albumsData.filter(album => album.Status.startsWith('-'));
|
||||||
|
} else {
|
||||||
|
filteredData = albumsData.filter(album => album.Status === filterValue);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset reverse state when filtering
|
||||||
|
resetReverse();
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
renderAlbums();
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle sort
|
||||||
|
function handleSort(event) {
|
||||||
|
const sortValue = event.target.value;
|
||||||
|
|
||||||
|
filteredData.sort((a, b) => {
|
||||||
|
let result;
|
||||||
|
switch (sortValue) {
|
||||||
|
case 'rank':
|
||||||
|
result = a.Rank - b.Rank;
|
||||||
|
break;
|
||||||
|
case 'artist':
|
||||||
|
result = a.Artist.localeCompare(b.Artist);
|
||||||
|
break;
|
||||||
|
case 'album':
|
||||||
|
result = a.Album.localeCompare(b.Album);
|
||||||
|
break;
|
||||||
|
case 'year':
|
||||||
|
const yearA = a.Year || 0;
|
||||||
|
const yearB = b.Year || 0;
|
||||||
|
result = yearB - yearA; // Most recent first
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
result = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply reverse if needed
|
||||||
|
return isReversed ? -result : result;
|
||||||
|
});
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
renderAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle reverse
|
||||||
|
function handleReverse() {
|
||||||
|
isReversed = !isReversed;
|
||||||
|
|
||||||
|
// Update button appearance
|
||||||
|
reverseButton.classList.toggle('reversed', isReversed);
|
||||||
|
|
||||||
|
// Reverse the current filtered data
|
||||||
|
filteredData.reverse();
|
||||||
|
|
||||||
|
currentPage = 1;
|
||||||
|
renderAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset reverse state
|
||||||
|
function resetReverse() {
|
||||||
|
if (isReversed) {
|
||||||
|
isReversed = false;
|
||||||
|
reverseButton.classList.remove('reversed');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle infinite scroll
|
||||||
|
function handleScroll() {
|
||||||
|
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 1000) {
|
||||||
|
if (currentPage * itemsPerPage < filteredData.length) {
|
||||||
|
currentPage++;
|
||||||
|
renderAlbums();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update statistics
|
||||||
|
function updateStats() {
|
||||||
|
const total = filteredData.length;
|
||||||
|
const newAlbums = filteredData.filter(album => album.Status === 'New in 2023').length;
|
||||||
|
const improved = filteredData.filter(album => album.Status.startsWith('+')).length;
|
||||||
|
const dropped = filteredData.filter(album => album.Status.startsWith('-')).length;
|
||||||
|
|
||||||
|
document.getElementById('totalAlbums').textContent = total;
|
||||||
|
document.getElementById('newAlbums').textContent = newAlbums;
|
||||||
|
document.getElementById('improvedAlbums').textContent = improved;
|
||||||
|
document.getElementById('droppedAlbums').textContent = dropped;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Utility functions
|
||||||
|
function debounce(func, wait) {
|
||||||
|
let timeout;
|
||||||
|
return function executedFunction(...args) {
|
||||||
|
const later = () => {
|
||||||
|
clearTimeout(timeout);
|
||||||
|
func(...args);
|
||||||
|
};
|
||||||
|
clearTimeout(timeout);
|
||||||
|
timeout = setTimeout(later, wait);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml(text) {
|
||||||
|
if (!text) return '';
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.textContent = text;
|
||||||
|
return div.innerHTML;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideLoading() {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
albumsGrid.style.display = 'grid';
|
||||||
|
}
|
||||||
|
|
||||||
|
function showError() {
|
||||||
|
loading.style.display = 'none';
|
||||||
|
error.style.display = 'block';
|
||||||
|
}
|
||||||
|
|
||||||
|
// Bookmark and navigation functions
|
||||||
|
function handleUrlParams() {
|
||||||
|
const urlParams = new URLSearchParams(window.location.search);
|
||||||
|
const hash = window.location.hash.slice(1);
|
||||||
|
|
||||||
|
// Handle rank from hash (#rank123) or query parameter (?rank=123)
|
||||||
|
const rankFromHash = hash.match(/^rank(\d+)$/);
|
||||||
|
const rankFromQuery = urlParams.get('rank');
|
||||||
|
const targetRank = rankFromHash ? parseInt(rankFromHash[1]) :
|
||||||
|
rankFromQuery ? parseInt(rankFromQuery) : null;
|
||||||
|
|
||||||
|
// Handle other URL parameters for filters/search first
|
||||||
|
const search = urlParams.get('search');
|
||||||
|
const status = urlParams.get('status');
|
||||||
|
const sort = urlParams.get('sort');
|
||||||
|
const reversed = urlParams.get('reversed') === 'true';
|
||||||
|
|
||||||
|
// Set up the state based on URL parameters
|
||||||
|
let delay = 1000;
|
||||||
|
|
||||||
|
if (search) {
|
||||||
|
searchInput.value = search;
|
||||||
|
setTimeout(() => handleSearch({target: searchInput}), delay);
|
||||||
|
delay += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status) {
|
||||||
|
statusFilter.value = status;
|
||||||
|
setTimeout(() => handleFilter({target: statusFilter}), delay);
|
||||||
|
delay += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sort) {
|
||||||
|
sortBy.value = sort;
|
||||||
|
setTimeout(() => handleSort({target: sortBy}), delay);
|
||||||
|
delay += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reversed) {
|
||||||
|
setTimeout(() => handleReverse(), delay);
|
||||||
|
delay += 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Finally, jump to the rank if specified
|
||||||
|
if (targetRank) {
|
||||||
|
setTimeout(() => jumpToRankWithCurrentState(targetRank), delay);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleJumpToRank() {
|
||||||
|
const inputValue = jumpToRank.value.trim();
|
||||||
|
let rank;
|
||||||
|
|
||||||
|
if (inputValue === '') {
|
||||||
|
// Default to rank 1 if no input
|
||||||
|
rank = 1;
|
||||||
|
jumpToRank.value = '1';
|
||||||
|
} else {
|
||||||
|
rank = parseInt(inputValue);
|
||||||
|
if (isNaN(rank) || rank < 1 || rank > 500) {
|
||||||
|
alert('Please enter a valid rank between 1 and 500');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
jumpToRankNumber(rank);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleRankKeypress(event) {
|
||||||
|
if (event.key === 'Enter') {
|
||||||
|
handleJumpToRank();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToRankNumber(rank) {
|
||||||
|
if (!albumsData.length) return;
|
||||||
|
|
||||||
|
// Reset filters to show all albums by rank
|
||||||
|
searchInput.value = '';
|
||||||
|
statusFilter.value = '';
|
||||||
|
sortBy.value = 'rank';
|
||||||
|
isReversed = false;
|
||||||
|
reverseButton.classList.remove('reversed');
|
||||||
|
|
||||||
|
// Reset data and apply rank sorting
|
||||||
|
filteredData = [...albumsData];
|
||||||
|
filteredData.sort((a, b) => a.Rank - b.Rank);
|
||||||
|
|
||||||
|
// Update URL - clear all params if jumping to rank 1, otherwise set rank
|
||||||
|
if (rank === 1) {
|
||||||
|
clearUrl();
|
||||||
|
} else {
|
||||||
|
updateUrl({rank});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find the album card and scroll to it
|
||||||
|
currentPage = Math.ceil(rank / itemsPerPage);
|
||||||
|
renderAlbums();
|
||||||
|
|
||||||
|
// Wait for render then scroll
|
||||||
|
setTimeout(() => scrollToRank(rank), 100);
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function jumpToRankWithCurrentState(rank) {
|
||||||
|
if (!albumsData.length) return;
|
||||||
|
|
||||||
|
// Don't reset filters - preserve current state
|
||||||
|
// Just ensure the target album is visible in current filtered data
|
||||||
|
|
||||||
|
// Find the album card and scroll to it
|
||||||
|
// First, make sure enough albums are loaded
|
||||||
|
const albumInFiltered = filteredData.find(album => album.Rank === rank);
|
||||||
|
if (albumInFiltered) {
|
||||||
|
const indexInFiltered = filteredData.indexOf(albumInFiltered);
|
||||||
|
currentPage = Math.ceil((indexInFiltered + 1) / itemsPerPage);
|
||||||
|
renderAlbums();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for render then scroll
|
||||||
|
setTimeout(() => scrollToRank(rank), 100);
|
||||||
|
|
||||||
|
updateStats();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToRank(rank) {
|
||||||
|
const albumCard = document.querySelector(`[data-rank="${rank}"]`);
|
||||||
|
if (albumCard) {
|
||||||
|
albumCard.scrollIntoView({
|
||||||
|
behavior: 'smooth',
|
||||||
|
block: 'center'
|
||||||
|
});
|
||||||
|
|
||||||
|
// Highlight the album briefly
|
||||||
|
albumCard.style.boxShadow = '0 0 20px rgba(102, 126, 234, 0.5)';
|
||||||
|
albumCard.style.transform = 'scale(1.02)';
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
albumCard.style.boxShadow = '';
|
||||||
|
albumCard.style.transform = '';
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAlbumShare(rank, buttonElement) {
|
||||||
|
const albumUrl = generateAlbumUrl(rank);
|
||||||
|
|
||||||
|
// Copy to clipboard
|
||||||
|
if (navigator.clipboard) {
|
||||||
|
navigator.clipboard.writeText(albumUrl).then(() => {
|
||||||
|
showAlbumShareFeedback(buttonElement);
|
||||||
|
}).catch(() => {
|
||||||
|
fallbackCopyToClipboard(albumUrl, buttonElement);
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
fallbackCopyToClipboard(albumUrl, buttonElement);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateAlbumUrl(rank) {
|
||||||
|
const baseUrl = window.location.origin + window.location.pathname;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Add current state parameters
|
||||||
|
if (statusFilter.value) {
|
||||||
|
params.set('status', statusFilter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy.value !== 'rank') {
|
||||||
|
params.set('sort', sortBy.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReversed) {
|
||||||
|
params.set('reversed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add search if present
|
||||||
|
if (searchInput.value.trim()) {
|
||||||
|
params.set('search', searchInput.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always add the rank for direct navigation
|
||||||
|
params.set('rank', rank);
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return queryString ? `${baseUrl}?${queryString}` : `${baseUrl}#rank${rank}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function generateShareableUrl() {
|
||||||
|
const baseUrl = window.location.origin + window.location.pathname;
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
// Add current filters/search
|
||||||
|
if (searchInput.value.trim()) {
|
||||||
|
params.set('search', searchInput.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter.value) {
|
||||||
|
params.set('status', statusFilter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy.value !== 'rank') {
|
||||||
|
params.set('sort', sortBy.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReversed) {
|
||||||
|
params.set('reversed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we're viewing a specific rank area, add it
|
||||||
|
const visibleCards = document.querySelectorAll('.album-card');
|
||||||
|
if (visibleCards.length > 0 && sortBy.value === 'rank' && !isReversed) {
|
||||||
|
const firstVisibleRank = visibleCards[0].getAttribute('data-rank');
|
||||||
|
if (firstVisibleRank) {
|
||||||
|
params.set('rank', firstVisibleRank);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
return queryString ? `${baseUrl}?${queryString}` : baseUrl;
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateUrl(options = {}) {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (options.rank) {
|
||||||
|
window.location.hash = `rank${options.rank}`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update query parameters based on current state
|
||||||
|
if (searchInput.value.trim()) {
|
||||||
|
params.set('search', searchInput.value.trim());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (statusFilter.value) {
|
||||||
|
params.set('status', statusFilter.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (sortBy.value !== 'rank') {
|
||||||
|
params.set('sort', sortBy.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isReversed) {
|
||||||
|
params.set('reversed', 'true');
|
||||||
|
}
|
||||||
|
|
||||||
|
const queryString = params.toString();
|
||||||
|
const newUrl = queryString ?
|
||||||
|
`${window.location.pathname}?${queryString}` :
|
||||||
|
window.location.pathname;
|
||||||
|
|
||||||
|
window.history.replaceState({}, '', newUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearUrl() {
|
||||||
|
// Clear both query parameters and hash
|
||||||
|
window.history.replaceState({}, '', window.location.pathname);
|
||||||
|
}
|
||||||
|
|
||||||
|
function showAlbumShareFeedback(buttonElement) {
|
||||||
|
const originalText = buttonElement.innerHTML;
|
||||||
|
buttonElement.innerHTML = '✓';
|
||||||
|
buttonElement.classList.add('copied');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
buttonElement.innerHTML = originalText;
|
||||||
|
buttonElement.classList.remove('copied');
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackCopyToClipboard(text, buttonElement) {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
|
||||||
|
try {
|
||||||
|
document.execCommand('copy');
|
||||||
|
if (buttonElement) {
|
||||||
|
showAlbumShareFeedback(buttonElement);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
alert('Unable to copy to clipboard. URL: ' + text);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.body.removeChild(textArea);
|
||||||
|
}
|
||||||
556
styles.css
Normal file
556
styles.css
Normal file
|
|
@ -0,0 +1,556 @@
|
||||||
|
/* Reset and base styles */
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #333;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Header */
|
||||||
|
.header {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid rgba(255, 255, 255, 0.2);
|
||||||
|
padding: 2rem 0;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title {
|
||||||
|
font-size: 3rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: white;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
text-shadow: 2px 2px 4px rgba(0,0,0,0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
color: rgba(255, 255, 255, 0.9);
|
||||||
|
font-weight: 300;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Main content */
|
||||||
|
.main {
|
||||||
|
padding: 2rem 0;
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Controls */
|
||||||
|
.controls {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.1);
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid #e1e5e9;
|
||||||
|
border-radius: 8px 0 0 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 8px 8px 0;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: background-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-button:hover {
|
||||||
|
background: #5a6fd8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid #e1e5e9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
background: white;
|
||||||
|
cursor: pointer;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-select:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reverse-button {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reverse-button:hover {
|
||||||
|
background: #5a6fd8;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reverse-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.reverse-button.reversed {
|
||||||
|
background: #764ba2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reverse-button.reversed:hover {
|
||||||
|
background: #6a4190;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reverseIcon {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reverse-button.reversed #reverseIcon {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bookmark controls */
|
||||||
|
.bookmark-controls {
|
||||||
|
display: flex;
|
||||||
|
gap: 1rem;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-to-rank {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-input {
|
||||||
|
width: 140px;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 2px solid #e1e5e9;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
outline: none;
|
||||||
|
transition: border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-input:focus {
|
||||||
|
border-color: #667eea;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-button {
|
||||||
|
padding: 0.75rem 1.25rem;
|
||||||
|
background: #667eea;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-button:hover {
|
||||||
|
background: #5a6fd8;
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.jump-button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
background: white;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 12px;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-item:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-number {
|
||||||
|
display: block;
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #666;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Albums grid */
|
||||||
|
.albums-grid {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card {
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: 0 4px 20px rgba(0,0,0,0.1);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1.5rem;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover {
|
||||||
|
width: 180px;
|
||||||
|
height: 180px;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
border-radius: 8px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 300;
|
||||||
|
box-shadow: 0 4px 12px rgba(0,0,0,0.15);
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: repeating-linear-gradient(
|
||||||
|
45deg,
|
||||||
|
transparent,
|
||||||
|
transparent 2px,
|
||||||
|
rgba(255,255,255,0.1) 2px,
|
||||||
|
rgba(255,255,255,0.1) 4px
|
||||||
|
);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
opacity: 0.8;
|
||||||
|
z-index: 1;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-share {
|
||||||
|
position: absolute;
|
||||||
|
top: 1rem;
|
||||||
|
right: 1rem;
|
||||||
|
background: rgba(102, 126, 234, 0.9);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
opacity: 0;
|
||||||
|
transform: scale(0.8);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover .album-share {
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-share:hover {
|
||||||
|
background: rgba(102, 126, 234, 1);
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-share:active {
|
||||||
|
transform: scale(0.95);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-share.copied {
|
||||||
|
background: rgba(23, 162, 184, 0.9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card:hover {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
box-shadow: 0 8px 32px rgba(0,0,0,0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-rank {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #667eea;
|
||||||
|
min-width: 80px;
|
||||||
|
text-align: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-content {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-title {
|
||||||
|
font-size: 1.3rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #333;
|
||||||
|
line-height: 1.3;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-artist {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
color: #666;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-info {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #888;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-status {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 0.25rem 0.75rem;
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
font-weight: 500;
|
||||||
|
align-self: flex-start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-new {
|
||||||
|
background: #e8f5e8;
|
||||||
|
color: #2d8f47;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-improved {
|
||||||
|
background: #e3f2fd;
|
||||||
|
color: #1976d2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-dropped {
|
||||||
|
background: #ffeaea;
|
||||||
|
color: #d32f2f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status-no-change {
|
||||||
|
background: #f5f5f5;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-description {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
color: #555;
|
||||||
|
line-height: 1.5;
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 3;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-description.expanded {
|
||||||
|
-webkit-line-clamp: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading and error states */
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading-spinner {
|
||||||
|
width: 50px;
|
||||||
|
height: 50px;
|
||||||
|
border: 3px solid rgba(255,255,255,0.3);
|
||||||
|
border-top: 3px solid white;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
margin: 0 auto 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
0% { transform: rotate(0deg); }
|
||||||
|
100% { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
|
||||||
|
.error {
|
||||||
|
text-align: center;
|
||||||
|
padding: 3rem 0;
|
||||||
|
color: #ff6b6b;
|
||||||
|
background: white;
|
||||||
|
border-radius: 12px;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Footer */
|
||||||
|
.footer {
|
||||||
|
background: rgba(0,0,0,0.1);
|
||||||
|
color: rgba(255,255,255,0.8);
|
||||||
|
text-align: center;
|
||||||
|
padding: 1.5rem 0;
|
||||||
|
margin-top: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive design */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.title {
|
||||||
|
font-size: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.subtitle {
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
min-width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filters {
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.reverse-button {
|
||||||
|
min-width: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#reverseText {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bookmark-controls {
|
||||||
|
width: 100%;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rank-input {
|
||||||
|
width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-rank {
|
||||||
|
min-width: auto;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
align-self: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-cover-icon {
|
||||||
|
font-size: 3rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: repeat(2, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 480px) {
|
||||||
|
.container {
|
||||||
|
padding: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.album-card {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue