Spaces:
Running
Running
<html lang="en"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>RSS Feed Dashboard</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> | |
<style> | |
.news-card { | |
transition: all 0.3s ease; | |
} | |
.news-card:hover { | |
transform: translateY(-5px); | |
box-shadow: 0 10px 20px rgba(0,0,0,0.1); | |
} | |
.source-tab { | |
transition: all 0.2s ease; | |
} | |
.source-tab:hover { | |
background-color: rgba(59, 130, 246, 0.1); | |
} | |
.source-tab.active { | |
border-left: 4px solid #3b82f6; | |
background-color: rgba(59, 130, 246, 0.05); | |
} | |
.fade-in { | |
animation: fadeIn 0.5s ease-in-out; | |
} | |
@keyframes fadeIn { | |
from { opacity: 0; } | |
to { opacity: 1; } | |
} | |
</style> | |
</head> | |
<body class="bg-gray-50 min-h-screen"> | |
<div class="container mx-auto px-4 py-8"> | |
<!-- Header --> | |
<header class="flex flex-col md:flex-row justify-between items-start md:items-center mb-8"> | |
<div class="mb-4 md:mb-0"> | |
<h1 class="text-3xl font-bold text-gray-800">RSS Feed Dashboard</h1> | |
<p class="text-gray-600">Stay updated with your favorite news sources</p> | |
</div> | |
<!-- Add Feed Form --> | |
<div class="w-full md:w-auto"> | |
<div class="flex flex-col md:flex-row gap-2"> | |
<input type="text" id="feedUrl" placeholder="Enter RSS feed URL" | |
class="px-4 py-2 border rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 w-full md:w-64"> | |
<button id="addFeedBtn" | |
class="bg-blue-500 hover:bg-blue-600 text-white px-4 py-2 rounded-lg flex items-center justify-center gap-2 transition-colors"> | |
<i class="fas fa-plus"></i> Add Feed | |
</button> | |
</div> | |
<p id="feedError" class="text-red-500 text-sm mt-1 hidden"></p> | |
</div> | |
</header> | |
<div class="flex flex-col lg:flex-row gap-6"> | |
<!-- Sidebar with sources --> | |
<div class="w-full lg:w-1/4 bg-white rounded-xl shadow-sm p-4 h-fit sticky top-4"> | |
<h2 class="text-xl font-semibold text-gray-800 mb-4 flex items-center gap-2"> | |
<i class="fas fa-newspaper text-blue-500"></i> News Sources | |
</h2> | |
<div class="space-y-2" id="sourcesList"> | |
<!-- Default sources will be added here --> | |
</div> | |
<div class="mt-6"> | |
<h3 class="text-sm font-medium text-gray-500 uppercase tracking-wider mb-2">Popular Sources</h3> | |
<div class="space-y-1"> | |
<button class="text-left w-full px-3 py-2 text-sm text-blue-500 hover:bg-blue-50 rounded-lg transition-colors add-default-feed" data-feed="http://feeds.bbci.co.uk/news/rss.xml"> | |
<i class="fas fa-globe-europe mr-2"></i> BBC News | |
</button> | |
<button class="text-left w-full px-3 py-2 text-sm text-blue-500 hover:bg-blue-50 rounded-lg transition-colors add-default-feed" data-feed="https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml"> | |
<i class="fas fa-newspaper mr-2"></i> New York Times | |
</button> | |
<button class="text-left w-full px-3 py-2 text-sm text-blue-500 hover:bg-blue-50 rounded-lg transition-colors add-default-feed" data-feed="https://www.theguardian.com/world/rss"> | |
<i class="fas fa-shield-alt mr-2"></i> The Guardian | |
</button> | |
</div> | |
</div> | |
</div> | |
<!-- Main content area --> | |
<div class="w-full lg:w-3/4"> | |
<div class="bg-white rounded-xl shadow-sm p-6"> | |
<div class="flex justify-between items-center mb-6"> | |
<h2 class="text-xl font-semibold text-gray-800" id="currentSourceTitle"> | |
<i class="fas fa-globe-americas text-blue-500 mr-2"></i> All News | |
</h2> | |
<div class="flex items-center gap-2"> | |
<span class="text-sm text-gray-500" id="lastUpdated"></span> | |
<button id="refreshBtn" class="text-blue-500 hover:text-blue-700 transition-colors"> | |
<i class="fas fa-sync-alt"></i> | |
</button> | |
</div> | |
</div> | |
<div id="loadingIndicator" class="flex justify-center items-center py-12"> | |
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-blue-500"></div> | |
</div> | |
<div id="newsContainer" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 hidden"> | |
<!-- News cards will be added here --> | |
</div> | |
<div id="noFeedsMessage" class="text-center py-12 hidden"> | |
<i class="fas fa-newspaper text-4xl text-gray-300 mb-4"></i> | |
<h3 class="text-xl font-medium text-gray-500">No feeds added yet</h3> | |
<p class="text-gray-400 mt-2">Add your first RSS feed to get started</p> | |
</div> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
document.addEventListener('DOMContentLoaded', function() { | |
// DOM elements | |
const feedUrlInput = document.getElementById('feedUrl'); | |
const addFeedBtn = document.getElementById('addFeedBtn'); | |
const feedError = document.getElementById('feedError'); | |
const sourcesList = document.getElementById('sourcesList'); | |
const newsContainer = document.getElementById('newsContainer'); | |
const loadingIndicator = document.getElementById('loadingIndicator'); | |
const noFeedsMessage = document.getElementById('noFeedsMessage'); | |
const currentSourceTitle = document.getElementById('currentSourceTitle'); | |
const lastUpdated = document.getElementById('lastUpdated'); | |
const refreshBtn = document.getElementById('refreshBtn'); | |
// State | |
let feeds = []; | |
let currentFeed = null; | |
let articles = []; | |
// Initialize with default feeds if none in localStorage | |
if (!localStorage.getItem('rssFeeds')) { | |
const defaultFeeds = [ | |
{ url: 'http://feeds.bbci.co.uk/news/rss.xml', name: 'BBC News', icon: 'globe-europe' }, | |
{ url: 'https://rss.nytimes.com/services/xml/rss/nyt/HomePage.xml', name: 'New York Times', icon: 'newspaper' }, | |
{ url: 'https://www.theguardian.com/world/rss', name: 'The Guardian', icon: 'microphone-alt' } | |
]; | |
localStorage.setItem('rssFeeds', JSON.stringify(defaultFeeds)); | |
} | |
// Load feeds from localStorage | |
loadFeeds(); | |
// Event listeners | |
addFeedBtn.addEventListener('click', addNewFeed); | |
feedUrlInput.addEventListener('keypress', function(e) { | |
if (e.key === 'Enter') addNewFeed(); | |
}); | |
// Add default feeds | |
document.querySelectorAll('.add-default-feed').forEach(btn => { | |
btn.addEventListener('click', function() { | |
feedUrlInput.value = this.dataset.feed; | |
addNewFeed(); | |
}); | |
}); | |
refreshBtn.addEventListener('click', function() { | |
if (currentFeed) { | |
fetchFeed(currentFeed.url); | |
} else if (feeds.length > 0) { | |
fetchAllFeeds(); | |
} | |
}); | |
// Functions | |
function loadFeeds() { | |
const savedFeeds = localStorage.getItem('rssFeeds'); | |
if (savedFeeds) { | |
feeds = JSON.parse(savedFeeds); | |
renderSourcesList(); | |
if (feeds.length > 0) { | |
fetchAllFeeds(); | |
} else { | |
showNoFeedsMessage(); | |
} | |
} | |
} | |
function renderSourcesList() { | |
sourcesList.innerHTML = ''; | |
// Add "All News" option | |
const allNewsTab = document.createElement('button'); | |
allNewsTab.className = 'source-tab w-full text-left px-3 py-2 rounded-lg flex items-center gap-2 font-medium'; | |
allNewsTab.innerHTML = ` | |
<i class="fas fa-globe-americas text-gray-500"></i> | |
<span>All News</span> | |
<span class="ml-auto bg-gray-100 text-gray-600 text-xs px-2 py-1 rounded-full">${feeds.length}</span> | |
`; | |
allNewsTab.addEventListener('click', function() { | |
document.querySelectorAll('.source-tab').forEach(tab => tab.classList.remove('active')); | |
this.classList.add('active'); | |
currentFeed = null; | |
currentSourceTitle.innerHTML = '<i class="fas fa-globe-americas text-blue-500 mr-2"></i> All News'; | |
renderArticles(articles); | |
}); | |
if (!currentFeed && feeds.length > 0) { | |
allNewsTab.classList.add('active'); | |
} | |
sourcesList.appendChild(allNewsTab); | |
// Add individual feeds | |
feeds.forEach(feed => { | |
const feedTab = document.createElement('button'); | |
feedTab.className = 'source-tab w-full text-left px-3 py-2 rounded-lg flex items-center gap-2'; | |
if (currentFeed && currentFeed.url === feed.url) { | |
feedTab.classList.add('active'); | |
} | |
feedTab.innerHTML = ` | |
<i class="fas fa-${feed.icon || 'rss'} text-gray-500"></i> | |
<span class="truncate">${feed.name || 'Unnamed Feed'}</span> | |
<button class="ml-auto text-gray-400 hover:text-red-500 delete-feed" data-url="${feed.url}"> | |
<i class="fas fa-times"></i> | |
</button> | |
`; | |
feedTab.addEventListener('click', function() { | |
document.querySelectorAll('.source-tab').forEach(tab => tab.classList.remove('active')); | |
this.classList.add('active'); | |
currentFeed = feed; | |
currentSourceTitle.innerHTML = ` | |
<i class="fas fa-${feed.icon || 'rss'} text-blue-500 mr-2"></i> ${feed.name || 'Unnamed Feed'} | |
`; | |
const filteredArticles = articles.filter(article => article.feedUrl === feed.url); | |
renderArticles(filteredArticles); | |
}); | |
sourcesList.appendChild(feedTab); | |
}); | |
// Add delete handlers | |
document.querySelectorAll('.delete-feed').forEach(btn => { | |
btn.addEventListener('click', function(e) { | |
e.stopPropagation(); | |
const urlToDelete = this.dataset.url; | |
deleteFeed(urlToDelete); | |
}); | |
}); | |
} | |
function addNewFeed() { | |
const feedUrl = feedUrlInput.value.trim(); | |
if (!feedUrl) { | |
showError('Please enter a feed URL'); | |
return; | |
} | |
// Basic URL validation | |
if (!isValidUrl(feedUrl)) { | |
showError('Please enter a valid URL'); | |
return; | |
} | |
// Check if feed already exists | |
if (feeds.some(feed => feed.url === feedUrl)) { | |
showError('This feed is already added'); | |
return; | |
} | |
hideError(); | |
// Add temporary feed with loading state | |
const tempFeed = { url: feedUrl, name: 'Loading...', icon: 'rss' }; | |
feeds.push(tempFeed); | |
saveFeeds(); | |
renderSourcesList(); | |
// Try to fetch the feed to get its title | |
fetchFeed(feedUrl, true) | |
.then(data => { | |
// Update feed name if we can extract it | |
const feedName = data.feed.title || new URL(feedUrl).hostname.replace('www.', ''); | |
const updatedFeed = { | |
url: feedUrl, | |
name: feedName, | |
icon: getIconForFeed(feedName) | |
}; | |
// Replace the temporary feed | |
const feedIndex = feeds.findIndex(f => f.url === feedUrl); | |
if (feedIndex !== -1) { | |
feeds[feedIndex] = updatedFeed; | |
saveFeeds(); | |
renderSourcesList(); | |
// If this is the only feed, show its articles | |
if (feeds.length === 1) { | |
const feedTab = document.querySelector(`.source-tab[data-url="${feedUrl}"]`); | |
if (feedTab) feedTab.click(); | |
} | |
} | |
}) | |
.catch(error => { | |
console.error('Error fetching feed:', error); | |
// If we can't fetch the feed, still keep it but with a generic name | |
const feedIndex = feeds.findIndex(f => f.url === feedUrl); | |
if (feedIndex !== -1) { | |
feeds[feedIndex] = { | |
url: feedUrl, | |
name: new URL(feedUrl).hostname.replace('www.', ''), | |
icon: 'rss' | |
}; | |
saveFeeds(); | |
renderSourcesList(); | |
} | |
}); | |
feedUrlInput.value = ''; | |
} | |
function deleteFeed(url) { | |
if (confirm('Are you sure you want to remove this feed?')) { | |
feeds = feeds.filter(feed => feed.url !== url); | |
saveFeeds(); | |
// Also remove articles from this feed | |
articles = articles.filter(article => article.feedUrl !== url); | |
// Update UI | |
renderSourcesList(); | |
if (currentFeed && currentFeed.url === url) { | |
// If we deleted the currently selected feed, show all articles | |
currentFeed = null; | |
const allNewsTab = sourcesList.querySelector('.source-tab:first-child'); | |
if (allNewsTab) allNewsTab.click(); | |
} | |
renderArticles(currentFeed ? | |
articles.filter(article => article.feedUrl === currentFeed.url) : | |
articles); | |
if (feeds.length === 0) { | |
showNoFeedsMessage(); | |
} | |
} | |
} | |
function fetchAllFeeds() { | |
loadingIndicator.classList.remove('hidden'); | |
newsContainer.classList.add('hidden'); | |
noFeedsMessage.classList.add('hidden'); | |
const fetchPromises = feeds.map(feed => fetchFeed(feed.url)); | |
Promise.all(fetchPromises) | |
.then(() => { | |
updateLastUpdated(); | |
renderArticles(articles); | |
}) | |
.catch(error => { | |
console.error('Error fetching some feeds:', error); | |
updateLastUpdated(); | |
renderArticles(articles); | |
}); | |
} | |
function fetchFeed(url, isNewFeed = false) { | |
if (!isNewFeed) { | |
loadingIndicator.classList.remove('hidden'); | |
newsContainer.classList.add('hidden'); | |
} | |
// Use a CORS proxy to avoid issues with RSS feeds that don't allow cross-origin requests | |
const proxyUrl = `https://api.allorigins.win/get?url=${encodeURIComponent(url)}`; | |
return fetch(proxyUrl) | |
.then(response => { | |
if (!response.ok) throw new Error('Network response was not ok'); | |
return response.json(); | |
}) | |
.then(data => { | |
// Parse the XML content | |
const parser = new DOMParser(); | |
const xmlDoc = parser.parseFromString(data.contents, "text/xml"); | |
// Extract feed title | |
let feedTitle = ''; | |
const titleElement = xmlDoc.querySelector('channel > title'); | |
if (titleElement) { | |
feedTitle = titleElement.textContent; | |
} | |
// Extract articles | |
const items = xmlDoc.querySelectorAll('item'); | |
const newArticles = []; | |
items.forEach(item => { | |
const title = item.querySelector('title')?.textContent || 'No title'; | |
const link = item.querySelector('link')?.textContent || '#'; | |
const description = item.querySelector('description')?.textContent || ''; | |
const pubDate = item.querySelector('pubDate')?.textContent || ''; | |
const imageElement = item.querySelector('enclosure[type^="image/"]') || | |
item.querySelector('media\\:content, content') || | |
item.querySelector('image'); | |
let imageUrl = ''; | |
if (imageElement) { | |
imageUrl = imageElement.getAttribute('url') || | |
imageElement.getAttribute('href') || | |
''; | |
} | |
// Try to extract image from description if not found | |
if (!imageUrl && description) { | |
const imgRegex = /<img[^>]+src="([^">]+)"/; | |
const match = description.match(imgRegex); | |
if (match) { | |
imageUrl = match[1]; | |
} | |
} | |
newArticles.push({ | |
title, | |
link, | |
description: cleanDescription(description), | |
pubDate, | |
imageUrl, | |
feedUrl: url, | |
feedName: feedTitle | |
}); | |
}); | |
// Remove old articles from this feed | |
articles = articles.filter(article => article.feedUrl !== url); | |
// Add new articles | |
articles = [...newArticles, ...articles]; | |
// Sort by date (newest first) | |
articles.sort((a, b) => { | |
const dateA = a.pubDate ? new Date(a.pubDate) : new Date(0); | |
const dateB = b.pubDate ? new Date(b.pubDate) : new Date(0); | |
return dateB - dateA; | |
}); | |
if (!isNewFeed) { | |
renderArticles(currentFeed ? | |
articles.filter(article => article.feedUrl === currentFeed.url) : | |
articles); | |
} | |
return { feed: { title: feedTitle }, articles: newArticles }; | |
}) | |
.catch(error => { | |
console.error('Error fetching feed:', error); | |
if (!isNewFeed) { | |
renderArticles(currentFeed ? | |
articles.filter(article => article.feedUrl === currentFeed.url) : | |
articles); | |
} | |
throw error; | |
}); | |
} | |
function renderArticles(articlesToShow) { | |
loadingIndicator.classList.add('hidden'); | |
if (articlesToShow.length === 0) { | |
newsContainer.classList.add('hidden'); | |
if (feeds.length > 0) { | |
noFeedsMessage.querySelector('h3').textContent = 'No articles found'; | |
noFeedsMessage.querySelector('p').textContent = 'Try refreshing or check the feed URL'; | |
noFeedsMessage.classList.remove('hidden'); | |
} else { | |
showNoFeedsMessage(); | |
} | |
return; | |
} | |
newsContainer.innerHTML = ''; | |
newsContainer.classList.remove('hidden'); | |
noFeedsMessage.classList.add('hidden'); | |
articlesToShow.forEach(article => { | |
const articleDate = article.pubDate ? new Date(article.pubDate) : null; | |
const timeAgo = articleDate ? getTimeAgo(articleDate) : ''; | |
const feed = feeds.find(f => f.url === article.feedUrl) || {}; | |
const card = document.createElement('div'); | |
card.className = 'news-card bg-white rounded-lg overflow-hidden border border-gray-100 hover:shadow-md transition-all fade-in'; | |
card.innerHTML = ` | |
<div class="h-48 overflow-hidden"> | |
${article.imageUrl ? | |
`<img src="${article.imageUrl}" alt="${article.title}" class="w-full h-full object-cover">` : | |
`<div class="w-full h-full bg-gray-100 flex items-center justify-center"> | |
<i class="fas fa-newspaper text-4xl text-gray-300"></i> | |
</div>`} | |
</div> | |
<div class="p-4"> | |
<div class="flex items-center gap-2 mb-2"> | |
<span class="text-xs font-medium px-2 py-1 bg-gray-100 rounded-full text-gray-600"> | |
${feed.name || article.feedName || 'Unknown Source'} | |
</span> | |
${timeAgo ? `<span class="text-xs text-gray-400">${timeAgo}</span>` : ''} | |
</div> | |
<h3 class="font-semibold text-lg mb-2 line-clamp-2">${article.title}</h3> | |
<p class="text-gray-500 text-sm mb-4 line-clamp-2">${article.description}</p> | |
<a href="${article.link}" target="_blank" rel="noopener noreferrer" | |
class="text-blue-500 hover:text-blue-700 text-sm font-medium flex items-center gap-1"> | |
Read more <i class="fas fa-external-link-alt text-xs"></i> | |
</a> | |
</div> | |
`; | |
newsContainer.appendChild(card); | |
}); | |
} | |
function showNoFeedsMessage() { | |
loadingIndicator.classList.add('hidden'); | |
newsContainer.classList.add('hidden'); | |
noFeedsMessage.classList.remove('hidden'); | |
noFeedsMessage.querySelector('h3').textContent = 'No feeds added yet'; | |
noFeedsMessage.querySelector('p').textContent = 'Add your first RSS feed to get started'; | |
} | |
function saveFeeds() { | |
localStorage.setItem('rssFeeds', JSON.stringify(feeds)); | |
} | |
function showError(message) { | |
feedError.textContent = message; | |
feedError.classList.remove('hidden'); | |
feedUrlInput.classList.add('border-red-500'); | |
} | |
function hideError() { | |
feedError.classList.add('hidden'); | |
feedUrlInput.classList.remove('border-red-500'); | |
} | |
function updateLastUpdated() { | |
lastUpdated.textContent = `Updated ${new Date().toLocaleTimeString()}`; | |
} | |
// Helper functions | |
function isValidUrl(string) { | |
try { | |
new URL(string); | |
return true; | |
} catch (_) { | |
return false; | |
} | |
} | |
function cleanDescription(description) { | |
// Remove HTML tags | |
const stripped = description.replace(/<[^>]*>?/gm, ''); | |
// Trim and clean up | |
return stripped.trim().replace(/\s+/g, ' '); | |
} | |
function getTimeAgo(date) { | |
const seconds = Math.floor((new Date() - date) / 1000); | |
let interval = Math.floor(seconds / 31536000); | |
if (interval >= 1) return `${interval} year${interval === 1 ? '' : 's'} ago`; | |
interval = Math.floor(seconds / 2592000); | |
if (interval >= 1) return `${interval} month${interval === 1 ? '' : 's'} ago`; | |
interval = Math.floor(seconds / 86400); | |
if (interval >= 1) return `${interval} day${interval === 1 ? '' : 's'} ago`; | |
interval = Math.floor(seconds / 3600); | |
if (interval >= 1) return `${interval} hour${interval === 1 ? '' : 's'} ago`; | |
interval = Math.floor(seconds / 60); | |
if (interval >= 1) return `${interval} minute${interval === 1 ? '' : 's'} ago`; | |
return 'Just now'; | |
} | |
function getIconForFeed(feedName) { | |
const lowerName = feedName.toLowerCase(); | |
if (lowerName.includes('bbc')) return 'globe-europe'; | |
if (lowerName.includes('new york times') || lowerName.includes('nytimes')) return 'newspaper'; | |
if (lowerName.includes('npr')) return 'microphone-alt'; | |
if (lowerName.includes('tech') || lowerName.includes('technology')) return 'laptop-code'; | |
if (lowerName.includes('sport') || lowerName.includes('espn')) return 'running'; | |
if (lowerName.includes('business') || lowerName.includes('economy')) return 'chart-line'; | |
if (lowerName.includes('science')) return 'flask'; | |
if (lowerName.includes('health')) return 'heartbeat'; | |
return 'rss'; | |
} | |
}); | |
</script> | |
</body> | |
</html> |