top500albums/script.js
Johan Lundberg 462fdcfa84 Complete Top 500 Albums project with 100% data coverage and UI improvements
- Fixed Info/Description columns after regenerating CSV with clean Wikipedia data
- Remapped and downloaded missing album covers to match new rankings
- Modified website UI to show all description text without click-to-expand
- Added comprehensive Info/Description for all 500 albums using research
- Created multiple data processing scripts for album information completion
- Achieved 100% data completion with descriptions ending "(by Claude)" for new content
- All albums now have complete metadata and cover art

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-07-01 00:33:47 +02:00

648 lines
No EOL
19 KiB
JavaScript

// 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);
});
}
// 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-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">
${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>
<button class="album-share" title="Share this album" data-rank="${album.Rank}">🔗</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';
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);
}