RSSFeedDashboard / index.html
helmo's picture
Update index.html
775ce64 verified
<!DOCTYPE html>
<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>