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> |