top500albums/script.js
Johan Lundberg fce2c6ff1e Implement comprehensive theme system and modernize UI
- Add 8 themes: Gruvbox (default), Basic Blue, Dark, Gruvbox Dark, Dracula, Nord, Solarized, Arc
- Replace emoji icons with clean SVG icons for search, reverse, and share buttons
- Add Rolling Stone Magazine's supertitle to header
- Implement theme selector in header with localStorage persistence
- Simplify jump-to-rank placeholder text to "Jump to..."
- Update all UI elements to use CSS custom properties for consistent theming
- Improve mobile responsiveness for theme selector and controls

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-02 00:17:36 +02:00

815 lines
No EOL
25 KiB
JavaScript

// Global variables
let albumsData = [];
let filteredData = [];
let currentPage = 1;
const itemsPerPage = 50;
let isReversed = false;
let wikipediaUrlMappings = {};
// 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 themeSelect = document.getElementById('themeSelect');
const loading = document.getElementById('loading');
const error = document.getElementById('error');
const stats = document.getElementById('stats');
// Initialize the application
document.addEventListener('DOMContentLoaded', function() {
loadWikipediaMapping();
loadAlbumsData();
setupEventListeners();
loadTheme();
handleUrlParams();
});
// Load Wikipedia URL mappings
async function loadWikipediaMapping() {
try {
const response = await fetch('wikipedia_urls_mapping.json');
if (response.ok) {
wikipediaUrlMappings = await response.json();
}
} catch (err) {
console.warn('Could not load Wikipedia URL mappings:', err);
}
}
// 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);
themeSelect.addEventListener('change', handleThemeChange);
// Add scroll listener for infinite scroll
window.addEventListener('scroll', handleScroll);
// Add click listeners to stats cards
setupStatsClickHandlers();
}
// Setup click handlers for stats cards
function setupStatsClickHandlers() {
const totalAlbumsCard = document.querySelector('.stat-item:has(#totalAlbums)') ||
document.querySelector('#totalAlbums').closest('.stat-item');
const newAlbumsCard = document.querySelector('.stat-item:has(#newAlbums)') ||
document.querySelector('#newAlbums').closest('.stat-item');
const improvedAlbumsCard = document.querySelector('.stat-item:has(#improvedAlbums)') ||
document.querySelector('#improvedAlbums').closest('.stat-item');
const droppedAlbumsCard = document.querySelector('.stat-item:has(#droppedAlbums)') ||
document.querySelector('#droppedAlbums').closest('.stat-item');
if (totalAlbumsCard) {
totalAlbumsCard.addEventListener('click', () => handleStatsCardClick(''));
totalAlbumsCard.style.cursor = 'pointer';
}
if (newAlbumsCard) {
newAlbumsCard.addEventListener('click', () => handleStatsCardClick('New in 2023'));
newAlbumsCard.style.cursor = 'pointer';
}
if (improvedAlbumsCard) {
improvedAlbumsCard.addEventListener('click', () => handleStatsCardClick('improved'));
improvedAlbumsCard.style.cursor = 'pointer';
}
if (droppedAlbumsCard) {
droppedAlbumsCard.addEventListener('click', () => handleStatsCardClick('dropped'));
droppedAlbumsCard.style.cursor = 'pointer';
}
}
// Handle stats card clicks
function handleStatsCardClick(filterValue) {
// Update the status filter dropdown
statusFilter.value = filterValue;
// Trigger the filter change
statusFilter.dispatchEvent(new Event('change'));
// Scroll to the albums grid
albumsGrid.scrollIntoView({ behavior: 'smooth' });
}
// Theme handling functions
function handleThemeChange(event) {
const theme = event.target.value;
setTheme(theme);
saveTheme(theme);
}
function setTheme(theme) {
if (theme) {
document.documentElement.setAttribute('data-theme', theme);
} else {
document.documentElement.removeAttribute('data-theme');
}
}
function saveTheme(theme) {
try {
localStorage.setItem('selectedTheme', theme);
} catch (e) {
console.warn('Could not save theme to localStorage:', e);
}
}
function loadTheme() {
try {
const savedTheme = localStorage.getItem('selectedTheme');
if (savedTheme !== null) {
setTheme(savedTheme);
themeSelect.value = savedTheme;
}
} catch (e) {
console.warn('Could not load theme from localStorage:', e);
}
}
// 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();
initializeStats();
} 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);
});
}
// Get cover image path for an album
function getCoverImagePath(album) {
if (!album.Artist || !album.Album || !album.Rank) return null;
// Sanitize artist and album names to match the downloaded filenames
const safeArtist = album.Artist.replace(/[<>:"/\\|?*]/g, '').replace(/[^\w\s\-_.]/g, '').replace(/\s+/g, '_').slice(0, 100);
const safeAlbum = album.Album.replace(/[<>:"/\\|?*]/g, '').replace(/[^\w\s\-_.]/g, '').replace(/\s+/g, '_').slice(0, 100);
const rank = album.Rank.toString().padStart(3, '0');
const filename = `rank_${rank}_${safeArtist}_${safeAlbum}.jpg`;
return `covers/${filename}`;
}
// 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);
const coverImagePath = getCoverImagePath(album);
card.innerHTML = `
<div class="album-header-grid">
<div class="album-rank">#${album.Rank}</div>
<div class="album-details">
<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>
</div>
<div class="album-cover">
${coverImagePath ?
`<img src="${coverImagePath}" alt="${escapeHtml(album.Album)} by ${escapeHtml(album.Artist)}" class="album-cover-image" onerror="this.style.display='none'; this.nextElementSibling.style.display='flex';">
<div class="album-cover-icon" style="display: none;">🎵</div>` :
`<div class="album-cover-icon">🎵</div>`
}
</div>
<div class="album-status ${statusClass}">${statusText}</div>
${album.Description ? `<div class="album-description">${formatDescription(album.Description)}</div>` : ''}
<div class="album-links">
<a href="${generateWikipediaUrl(album.Album, album.Artist)}" target="_blank" rel="noopener noreferrer" class="wikipedia-link">
View on Wikipedia →
</a>
</div>
<button class="album-share" title="Share this album" data-rank="${album.Rank}">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8"></path>
<polyline points="16,6 12,2 8,6"></polyline>
<line x1="12" y1="2" x2="12" y2="15"></line>
</svg>
</button>
`;
// Description is now always fully visible
// 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';
if (status.startsWith('Dropped')) 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}`;
if (status.startsWith('Dropped')) 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)
);
}
// 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('-') || album.Status.startsWith('Dropped'));
} 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
// Initialize stats with static values from the full dataset (called only once)
function initializeStats() {
const total = albumsData.length;
const newAlbums = albumsData.filter(album => album.Status === 'New in 2023').length;
const improved = albumsData.filter(album => album.Status.startsWith('+')).length;
const dropped = albumsData.filter(album => album.Status.startsWith('-') || album.Status.startsWith('Dropped')).length;
document.getElementById('totalAlbums').textContent = total;
document.getElementById('newAlbums').textContent = newAlbums;
document.getElementById('improvedAlbums').textContent = improved;
document.getElementById('droppedAlbums').textContent = dropped;
}
// Update stats function (now empty but kept for compatibility)
function updateStats() {
// Stats remain static and don't update when filtering
}
// 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 formatDescription(text) {
if (!text) return '';
// Split by double line breaks or periods followed by capital letters
const paragraphs = text
.split(/\n\n|\. (?=[A-Z])/)
.map(p => p.trim())
.filter(p => p.length > 0)
.map(p => p.endsWith('.') ? p : p + '.');
// If we only have one paragraph, try to split by sentences for better formatting
if (paragraphs.length === 1 && text.length > 200) {
const sentences = text.match(/[^.!?]+[.!?]+/g) || [text];
if (sentences.length > 3) {
// Group sentences into 2-3 paragraphs
const third = Math.ceil(sentences.length / 3);
return [
sentences.slice(0, third).join(' '),
sentences.slice(third, third * 2).join(' '),
sentences.slice(third * 2).join(' ')
].filter(p => p.trim()).map(p => `<p>${escapeHtml(p.trim())}</p>`).join('');
}
}
return paragraphs.map(p => `<p>${escapeHtml(p)}</p>`).join('');
}
function generateWikipediaUrl(album, artist) {
// Clean up album and artist names
const albumName = album.trim();
const artistName = artist.trim();
// Check if we have the exact Wikipedia URL from our mapping
if (wikipediaUrlMappings[albumName]) {
return `https://en.wikipedia.org/wiki/${wikipediaUrlMappings[albumName]}`;
}
// Remove "The" from the beginning of artist names for URL formatting
const artistForUrl = artistName.replace(/^The /, '');
// Format for Wikipedia URL - replace spaces with underscores
const albumUrl = albumName.replace(/ /g, '_');
const artistUrl = artistForUrl.replace(/ /g, '_');
// Default pattern: Album_Name_(Artist_Name_album)
return `https://en.wikipedia.org/wiki/${encodeURIComponent(albumUrl)}_(${encodeURIComponent(artistUrl)}_album)`;
}
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 > 589) {
alert('Please enter a valid rank between 1 and 589');
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);
}