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:
Johan Lundberg 2025-06-30 21:51:10 +02:00
parent 97ea973de0
commit 25f453e3d9
8 changed files with 1649 additions and 0 deletions

157
create-favicon.html Normal file
View 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
View 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
View 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
View 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
View 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
View 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>&copy; 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
View 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
View 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;
}
}