This commit is contained in:
2025-11-12 13:35:59 +01:00
parent d59372d1d6
commit ce6c2f88bd
11 changed files with 1335 additions and 85 deletions

View File

@@ -1,46 +1,268 @@
// Pagination state
let allArticles = [];
let filteredArticles = [];
let displayedCount = 0;
const ARTICLES_PER_PAGE = 5;
let isLoading = false;
let searchQuery = '';
// Load news on page load
document.addEventListener('DOMContentLoaded', () => {
loadNews();
loadStats();
setupInfiniteScroll();
});
async function loadNews() {
const newsGrid = document.getElementById('newsGrid');
newsGrid.innerHTML = '<div class="loading">Loading news...</div>';
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Loading news...</div>';
try {
const response = await fetch('/api/news');
const data = await response.json();
if (data.articles && data.articles.length > 0) {
displayNews(data.articles);
allArticles = data.articles;
filteredArticles = data.articles;
displayedCount = 0;
newsGrid.innerHTML = '';
updateSearchStats();
loadMoreArticles();
} else {
newsGrid.innerHTML = '<div class="loading">No news available at the moment. Check back later!</div>';
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">No news available at the moment. Check back later!</div>';
}
} catch (error) {
console.error('Error loading news:', error);
newsGrid.innerHTML = '<div class="loading">Failed to load news. Please try again later.</div>';
newsGrid.innerHTML = '<div class="text-center py-10 text-gray-500">Failed to load news. Please try again later.</div>';
}
}
function displayNews(articles) {
function loadMoreArticles() {
if (isLoading || displayedCount >= filteredArticles.length) return;
isLoading = true;
const newsGrid = document.getElementById('newsGrid');
// Remove loading indicator if exists
const loadingIndicator = document.getElementById('loadingIndicator');
if (loadingIndicator) loadingIndicator.remove();
// Get next batch of articles
const nextBatch = filteredArticles.slice(displayedCount, displayedCount + ARTICLES_PER_PAGE);
nextBatch.forEach((article, index) => {
const card = createNewsCard(article, displayedCount + index);
newsGrid.appendChild(card);
});
displayedCount += nextBatch.length;
// Add loading indicator if more articles available
if (displayedCount < filteredArticles.length) {
const loader = document.createElement('div');
loader.id = 'loadingIndicator';
loader.className = 'text-center py-8 text-gray-400';
loader.innerHTML = '<div class="inline-block animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div><p class="mt-2">Loading more...</p>';
newsGrid.appendChild(loader);
} else if (filteredArticles.length > 0) {
// Add end message
const endMessage = document.createElement('div');
endMessage.className = 'text-center py-8 text-gray-400 text-sm';
endMessage.textContent = `✓ All ${filteredArticles.length} articles loaded`;
newsGrid.appendChild(endMessage);
}
isLoading = false;
}
function setupInfiniteScroll() {
window.addEventListener('scroll', () => {
if (isLoading || displayedCount >= filteredArticles.length) return;
const scrollPosition = window.innerHeight + window.scrollY;
const threshold = document.documentElement.scrollHeight - 500;
if (scrollPosition >= threshold) {
loadMoreArticles();
}
});
}
// Search functionality
function handleSearch() {
const searchInput = document.getElementById('searchInput');
const clearBtn = document.getElementById('clearSearch');
searchQuery = searchInput.value.trim().toLowerCase();
// Show/hide clear button
if (searchQuery) {
clearBtn.classList.remove('hidden');
} else {
clearBtn.classList.add('hidden');
}
// Filter articles
if (searchQuery === '') {
filteredArticles = allArticles;
} else {
filteredArticles = allArticles.filter(article => {
const title = article.title.toLowerCase();
const summary = (article.summary || '').toLowerCase().replace(/<[^>]*>/g, '');
const source = formatSourceName(article.source).toLowerCase();
return title.includes(searchQuery) ||
summary.includes(searchQuery) ||
source.includes(searchQuery);
});
}
// Reset display
displayedCount = 0;
const newsGrid = document.getElementById('newsGrid');
newsGrid.innerHTML = '';
articles.forEach(article => {
const card = document.createElement('div');
card.className = 'news-card';
card.onclick = () => window.open(article.link, '_blank');
card.innerHTML = `
<div class="source">${article.source || 'Munich News'}</div>
<h3>${article.title}</h3>
<p>${article.summary || 'No summary available.'}</p>
<a href="${article.link}" target="_blank" class="read-more" onclick="event.stopPropagation()">Read more →</a>
// Update stats
updateSearchStats();
// Load filtered articles
if (filteredArticles.length > 0) {
loadMoreArticles();
} else {
newsGrid.innerHTML = `
<div class="text-center py-16">
<div class="text-6xl mb-4">🔍</div>
<p class="text-xl text-gray-600 mb-2">No articles found</p>
<p class="text-gray-400">Try a different search term</p>
</div>
`;
newsGrid.appendChild(card);
});
}
}
function clearSearch() {
const searchInput = document.getElementById('searchInput');
searchInput.value = '';
handleSearch();
searchInput.focus();
}
function updateSearchStats() {
const searchStats = document.getElementById('searchStats');
if (searchQuery) {
searchStats.textContent = `Found ${filteredArticles.length} of ${allArticles.length} articles`;
} else {
searchStats.textContent = `Showing ${allArticles.length} articles`;
}
}
function createNewsCard(article, index) {
const card = document.createElement('div');
card.className = 'group bg-white rounded-xl overflow-hidden shadow-md hover:shadow-xl transition-all duration-300 cursor-pointer border border-gray-100 hover:border-primary/30';
card.onclick = () => window.open(article.link, '_blank');
// Extract image from summary if it's an img tag (from Süddeutsche)
let imageUrl = null;
let cleanSummary = article.summary || 'No summary available.';
if (cleanSummary.includes('<img')) {
const imgMatch = cleanSummary.match(/src="([^"]+)"/);
if (imgMatch) {
imageUrl = imgMatch[1];
}
// Remove img tag from summary
cleanSummary = cleanSummary.replace(/<img[^>]*>/g, '').replace(/<\/?p>/g, '').trim();
}
// Get source icon/emoji
const sourceIcon = getSourceIcon(article.source);
// Format source name
const sourceName = formatSourceName(article.source);
// Get word count badge
const wordCount = article.word_count || article.summary_word_count;
const readTime = wordCount ? Math.ceil(wordCount / 200) : null;
card.innerHTML = `
<div class="flex flex-col sm:flex-row">
<!-- Image -->
<div class="relative w-full sm:w-64 h-48 sm:h-auto bg-gradient-to-br from-primary to-secondary flex items-center justify-center text-white text-5xl overflow-hidden flex-shrink-0">
${imageUrl ? `<img src="${imageUrl}" alt="${article.title}" class="w-full h-full object-cover" onerror="this.style.display='none'; this.parentElement.innerHTML='${sourceIcon}';">` : sourceIcon}
</div>
<!-- Content -->
<div class="p-6 flex flex-col flex-1">
<!-- Source Badge -->
<div class="flex items-center gap-2 mb-3">
<span class="w-1.5 h-1.5 bg-primary rounded-full"></span>
<span class="text-xs font-bold text-primary uppercase tracking-wide">${sourceName}</span>
${readTime ? `<span class="text-xs text-gray-400 ml-auto">📖 ${readTime} min read</span>` : ''}
</div>
<!-- Title -->
<h3 class="text-xl font-bold text-gray-900 mb-3 leading-tight group-hover:text-primary transition-colors">${article.title}</h3>
<!-- Summary -->
<p class="text-sm text-gray-600 mb-4 leading-relaxed line-clamp-2">${cleanSummary}</p>
<!-- Footer -->
<div class="flex items-center gap-4 mt-auto">
<a href="${article.link}" target="_blank" onclick="event.stopPropagation()" class="text-primary font-semibold text-sm hover:gap-2 flex items-center gap-1 transition-all">
Read full article <span class="group-hover:translate-x-1 transition-transform">→</span>
</a>
</div>
</div>
</div>
`;
// Add staggered animation
card.style.opacity = '0';
card.style.animation = `fadeIn 0.5s ease-out ${(index % ARTICLES_PER_PAGE) * 0.1}s forwards`;
return card;
}
// Add animation keyframes
if (!document.getElementById('animations')) {
const style = document.createElement('style');
style.id = 'animations';
style.textContent = `
@keyframes fadeIn {
from { opacity: 0; transform: translateY(20px); }
to { opacity: 1; transform: translateY(0); }
}
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
`;
document.head.appendChild(style);
}
function getSourceIcon(source) {
const icons = {
'abendzeitung-muenchen': '📰',
'sueddeutsche': '📄',
'muenchen': '🏛️',
'default': '📰'
};
return icons[source] || icons.default;
}
function formatSourceName(source) {
const names = {
'abendzeitung-muenchen': 'Abendzeitung München',
'sueddeutsche': 'Süddeutsche Zeitung',
'muenchen': 'München.de'
};
return names[source] || source.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
}
async function loadStats() {
@@ -65,12 +287,13 @@ async function subscribe() {
if (!email || !email.includes('@')) {
formMessage.textContent = 'Please enter a valid email address';
formMessage.className = 'form-message error';
formMessage.className = 'text-red-200 font-medium';
return;
}
subscribeBtn.disabled = true;
subscribeBtn.textContent = 'Subscribing...';
subscribeBtn.classList.add('opacity-75', 'cursor-not-allowed');
formMessage.textContent = '';
try {
@@ -86,19 +309,20 @@ async function subscribe() {
if (response.ok) {
formMessage.textContent = data.message || 'Successfully subscribed! Check your email for confirmation.';
formMessage.className = 'form-message success';
formMessage.className = 'text-green-200 font-medium';
emailInput.value = '';
loadStats(); // Refresh stats
} else {
formMessage.textContent = data.error || 'Failed to subscribe. Please try again.';
formMessage.className = 'form-message error';
formMessage.className = 'text-red-200 font-medium';
}
} catch (error) {
formMessage.textContent = 'Network error. Please try again later.';
formMessage.className = 'form-message error';
formMessage.className = 'text-red-200 font-medium';
} finally {
subscribeBtn.disabled = false;
subscribeBtn.textContent = 'Subscribe Free';
subscribeBtn.classList.remove('opacity-75', 'cursor-not-allowed');
}
}
@@ -110,13 +334,14 @@ document.getElementById('emailInput').addEventListener('keypress', (e) => {
});
function showUnsubscribe() {
document.getElementById('unsubscribeModal').style.display = 'block';
document.getElementById('unsubscribeModal').classList.remove('hidden');
}
function closeUnsubscribe() {
document.getElementById('unsubscribeModal').style.display = 'none';
document.getElementById('unsubscribeModal').classList.add('hidden');
document.getElementById('unsubscribeEmail').value = '';
document.getElementById('unsubscribeMessage').textContent = '';
document.getElementById('unsubscribeMessage').className = '';
}
async function unsubscribe() {
@@ -127,7 +352,7 @@ async function unsubscribe() {
if (!email || !email.includes('@')) {
unsubscribeMessage.textContent = 'Please enter a valid email address';
unsubscribeMessage.className = 'form-message error';
unsubscribeMessage.className = 'text-red-600 font-medium';
return;
}
@@ -144,7 +369,7 @@ async function unsubscribe() {
if (response.ok) {
unsubscribeMessage.textContent = data.message || 'Successfully unsubscribed.';
unsubscribeMessage.className = 'form-message success';
unsubscribeMessage.className = 'text-green-600 font-medium';
emailInput.value = '';
setTimeout(() => {
closeUnsubscribe();
@@ -152,11 +377,11 @@ async function unsubscribe() {
}, 2000);
} else {
unsubscribeMessage.textContent = data.error || 'Failed to unsubscribe. Please try again.';
unsubscribeMessage.className = 'form-message error';
unsubscribeMessage.className = 'text-red-600 font-medium';
}
} catch (error) {
unsubscribeMessage.textContent = 'Network error. Please try again later.';
unsubscribeMessage.className = 'form-message error';
unsubscribeMessage.className = 'text-red-600 font-medium';
}
}