ijohn07's picture
Upload 6 files
c248ea4 verified
/* ============================================
IVY'S RSS HUB — Main Application
Orchestrates the RSS feed aggregator
============================================ */
/**
* Shared Utilities — Used by both App and Sidebar
* Avoids code duplication across modules
*/
window.IvyUtils = {
/**
* Escape HTML to prevent XSS
*/
escapeHtml(text) {
if (!text) return "";
const div = document.createElement("div");
div.textContent = text;
return div.innerHTML;
},
/**
* Escape text for use in HTML attributes
*/
escapeAttr(text) {
if (!text) return "";
return text
.replace(/&/g, "&")
.replace(/"/g, """)
.replace(/'/g, "'")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;");
},
/**
* Truncate text with ellipsis
*/
truncate(text, maxLength) {
if (!text || text.length <= maxLength) return text;
return text.substring(0, maxLength) + "...";
}
};
/**
* RSSHub Application
*/
class RSSHubApp {
constructor() {
// Initialize parser
this.parser = new RSSParser();
// State
this.feeds = [];
this.feedResults = [];
this.currentCategory = "all";
this.currentLang = "all"; // Language filter: "all", "en", "fr"
this.settings = this.loadSettings();
this.isRefreshing = false; // Prevent double-refresh race conditions
this.autoRefreshTimer = null; // Auto-refresh interval timer
this.nextRefreshTime = null; // Timestamp of next auto-refresh
this.countdownTimer = null; // Countdown display timer
// DOM Elements
this.elements = {
feedsContainer: document.getElementById("feeds-container"),
statusText: document.getElementById("status-text"),
statusTime: document.getElementById("status-time"),
btnRefresh: document.getElementById("btn-refresh"),
btnTheme: document.getElementById("btn-theme"),
btnSettings: document.getElementById("btn-settings"),
btnAbout: document.getElementById("btn-about"),
modalAbout: document.getElementById("modal-about"),
modalSettings: document.getElementById("modal-settings"),
modalCloseAbout: document.getElementById("modal-close-about"),
modalCloseSettings: document.getElementById("modal-close-settings"),
sourcesList: document.getElementById("sources-list"),
navButtons: document.querySelectorAll(".nav-btn"),
langButtons: document.querySelectorAll(".lang-btn"),
sidebar: document.getElementById("sidebar")
};
// Initialize theme
this.initTheme();
// Initialize
this.init();
}
/**
* Initialize theme from localStorage or system preference
*/
initTheme() {
const savedTheme = localStorage.getItem("ivy-rss-hub-theme");
if (savedTheme) {
// User has a saved preference
document.documentElement.setAttribute("data-theme", savedTheme);
this.updateThemeIcon(savedTheme);
} else {
// Use system preference (CSS handles it, but we update the icon)
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
this.updateThemeIcon(prefersDark ? "dark" : "light");
}
// Listen for system theme changes
window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", e => {
if (!localStorage.getItem("ivy-rss-hub-theme")) {
this.updateThemeIcon(e.matches ? "dark" : "light");
}
});
}
/**
* Toggle between light and dark theme
*/
toggleTheme() {
const currentTheme = document.documentElement.getAttribute("data-theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
// Determine current effective theme
let effectiveTheme;
if (currentTheme) {
effectiveTheme = currentTheme;
} else {
effectiveTheme = prefersDark ? "dark" : "light";
}
// Toggle
const newTheme = effectiveTheme === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", newTheme);
localStorage.setItem("ivy-rss-hub-theme", newTheme);
this.updateThemeIcon(newTheme);
this.showToast(`${newTheme === "dark" ? "🌙 Dark" : "☀️ Light"} theme activated`, "info");
}
/**
* Update theme toggle button icon
*/
updateThemeIcon(theme) {
if (this.elements.btnTheme) {
this.elements.btnTheme.textContent = theme === "dark" ? "🌙" : "☀️";
this.elements.btnTheme.title = `Switch to ${theme === "dark" ? "light" : "dark"} theme`;
}
}
/**
* Initialize the application
*/
init() {
// Load feeds from settings or defaults
this.feeds = this.settings.feeds || [...window.FeedsConfig.DEFAULT_FEEDS];
// Save if migration occurred
if (this._needsMigrationSave) {
console.log("[Migration] Saving migrated feed configuration");
this.saveSettings();
this._needsMigrationSave = false;
}
// Setup event listeners
this.setupEventListeners();
// Setup scroll propagation for nested scrollable containers
this.setupScrollPropagation();
// Render settings
this.renderSourcesList();
// Initialize sidebar
this.sidebar = new SidebarManager(this);
window.sidebar = this.sidebar; // Expose for event handlers
// Initialize settings UI from saved values
this.initSettingsUI();
// Initial fetch
this.refreshFeeds();
// Start auto-refresh if enabled
if (this.settings.autoRefresh) {
this.startAutoRefresh();
}
}
/**
* Initialize settings UI elements from saved settings
*/
initSettingsUI() {
const groupBySourceCheckbox = document.getElementById("opt-group-by-source");
const showDescriptionsCheckbox = document.getElementById("opt-show-descriptions");
const maxItemsInput = document.getElementById("opt-max-items");
const autoRefreshCheckbox = document.getElementById("opt-auto-refresh");
const refreshIntervalSelect = document.getElementById("opt-refresh-interval");
if (groupBySourceCheckbox) {
groupBySourceCheckbox.checked = this.settings.groupBySource !== false;
}
if (showDescriptionsCheckbox) {
showDescriptionsCheckbox.checked = this.settings.showDescriptions !== false;
}
if (maxItemsInput) {
maxItemsInput.value = this.settings.maxItems || 10;
}
if (autoRefreshCheckbox) {
autoRefreshCheckbox.checked = this.settings.autoRefresh === true;
}
if (refreshIntervalSelect) {
refreshIntervalSelect.value = this.settings.refreshInterval || 10;
}
this.updateAutoRefreshStatus();
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Refresh button - normal click uses cache, shift+click forces refresh
this.elements.btnRefresh.addEventListener("click", async e => {
// Prevent double-click while already loading
if (this.isRefreshing) {
return;
}
if (e.shiftKey) {
// Force refresh - clear all cache including IndexedDB
await this.parser.clearAllCache();
this.showToast("Cache cleared! Refreshing...", "info");
console.log("🔄 Force refresh - cache cleared");
}
// Normal refresh uses cache if available
this.refreshFeeds();
});
// Add tooltip hint
this.elements.btnRefresh.title = "Refresh feeds (Shift+Click to force refresh)";
// Theme toggle
this.elements.btnTheme?.addEventListener("click", () => {
this.toggleTheme();
});
// About modal
this.elements.btnAbout.addEventListener("click", () => {
this.elements.modalAbout.classList.add("active");
// Focus the close button for accessibility
setTimeout(() => this.elements.modalCloseAbout.focus(), 100);
});
this.elements.modalCloseAbout.addEventListener("click", () => {
this.elements.modalAbout.classList.remove("active");
});
// Settings modal
this.elements.btnSettings.addEventListener("click", () => {
this.renderSourcesList();
this.elements.modalSettings.classList.add("active");
// Focus the close button for accessibility
setTimeout(() => this.elements.modalCloseSettings.focus(), 100);
});
this.elements.modalCloseSettings.addEventListener("click", () => {
this.elements.modalSettings.classList.remove("active");
});
// Close modals on overlay click
[this.elements.modalAbout, this.elements.modalSettings].forEach(modal => {
modal.addEventListener("click", e => {
if (e.target === modal) {
modal.classList.remove("active");
}
});
});
// Focus trap for modals (accessibility)
document.addEventListener("keydown", e => {
if (e.key !== "Tab") return;
// Check if a modal is open
const activeModal = document.querySelector(".modal-overlay.active .modal");
if (!activeModal) return;
// Get all focusable elements in the modal
const focusableElements = activeModal.querySelectorAll(
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
);
const firstElement = focusableElements[0];
const lastElement = focusableElements[focusableElements.length - 1];
// Trap focus within modal
if (e.shiftKey && document.activeElement === firstElement) {
e.preventDefault();
lastElement.focus();
} else if (!e.shiftKey && document.activeElement === lastElement) {
e.preventDefault();
firstElement.focus();
}
});
// Keyboard shortcuts
document.addEventListener("keydown", e => {
// Don't trigger shortcuts when typing in inputs
if (e.target.tagName === "INPUT" || e.target.tagName === "TEXTAREA") {
// Escape to blur inputs
if (e.key === "Escape") e.target.blur();
return;
}
// Escape to close modals and sidebar
if (e.key === "Escape") {
this.elements.modalAbout.classList.remove("active");
this.elements.modalSettings.classList.remove("active");
// Also close mobile sidebar
this.elements.sidebar?.classList.remove("open");
}
// R to refresh (Shift+R for force refresh)
if (e.key === "r" || e.key === "R") {
e.preventDefault();
if (e.shiftKey) {
this.parser.clearAllCache().then(() => {
this.showToast("Cache cleared! Refreshing...", "info");
this.refreshFeeds();
});
} else {
this.refreshFeeds();
}
}
// / to focus search
if (e.key === "/") {
e.preventDefault();
const searchInput = document.getElementById("search-input");
if (searchInput) {
searchInput.focus();
// Open sidebar on mobile
if (window.innerWidth <= 1100) {
this.elements.sidebar?.classList.add("open");
}
}
}
// S to toggle sidebar on desktop
if (e.key === "s" && !e.ctrlKey && !e.metaKey) {
const sidebar = document.getElementById("sidebar");
if (sidebar) sidebar.classList.toggle("hidden-desktop");
}
// T to toggle theme
if (e.key === "t" && !e.ctrlKey && !e.metaKey) {
this.toggleTheme();
}
// Home or ArrowUp (when not in input) to scroll to top
if (e.key === "Home" || (e.key === "ArrowUp" && e.ctrlKey)) {
e.preventDefault();
this.scrollToTop();
}
});
// Back to top button
this.setupBackToTop();
// Category navigation
this.elements.navButtons.forEach(btn => {
btn.addEventListener("click", () => {
this.setCategory(btn.dataset.category);
});
});
// Add custom feed (use AbortController pattern to prevent duplicate listeners)
const addFeedBtn = document.getElementById("btn-add-feed");
if (addFeedBtn && !addFeedBtn.dataset.listenerAttached) {
addFeedBtn.addEventListener("click", () => this.addCustomFeed());
addFeedBtn.dataset.listenerAttached = "true";
}
// Settings options
document.getElementById("opt-group-by-source")?.addEventListener("change", e => {
this.settings.groupBySource = e.target.checked;
this.saveSettings();
this.renderFeeds();
});
document.getElementById("opt-show-descriptions")?.addEventListener("change", e => {
this.settings.showDescriptions = e.target.checked;
this.saveSettings();
this.renderFeeds();
});
document.getElementById("opt-max-items")?.addEventListener("change", e => {
this.settings.maxItems = parseInt(e.target.value) || 20;
this.saveSettings();
this.renderFeeds();
});
// Auto-refresh settings
document.getElementById("opt-auto-refresh")?.addEventListener("change", e => {
this.settings.autoRefresh = e.target.checked;
this.saveSettings();
if (e.target.checked) {
this.startAutoRefresh();
this.showToast("Auto-refresh enabled! ⏰", "success");
} else {
this.stopAutoRefresh();
this.showToast("Auto-refresh disabled", "info");
}
});
document.getElementById("opt-refresh-interval")?.addEventListener("change", e => {
this.settings.refreshInterval = parseInt(e.target.value) || 10;
this.saveSettings();
// Restart timer with new interval if auto-refresh is enabled
if (this.settings.autoRefresh) {
this.startAutoRefresh();
this.showToast(`Refresh interval set to ${e.target.value} minutes`, "success");
}
});
// Enable/Disable all feeds buttons
document.getElementById("btn-enable-all")?.addEventListener("click", () => {
this.feeds.forEach(f => (f.enabled = true));
this.saveSettings();
this.renderSourcesList();
this.showToast("All feeds enabled!", "success");
});
document.getElementById("btn-disable-all")?.addEventListener("click", () => {
this.feeds.forEach(f => (f.enabled = false));
this.saveSettings();
this.renderSourcesList();
this.showToast("All feeds disabled", "info");
});
// Language filter buttons
this.elements.langButtons.forEach(btn => {
btn.addEventListener("click", () => {
this.setLanguage(btn.dataset.lang);
});
});
// Reset all data button
document.getElementById("btn-reset-all")?.addEventListener("click", () => {
this.resetAllData();
});
}
/**
* Set active category filter
*/
setCategory(category) {
this.currentCategory = category;
// Update nav buttons
this.elements.navButtons.forEach(btn => {
btn.classList.toggle("active", btn.dataset.category === category);
});
// Re-render feeds
this.renderFeeds();
}
/**
* Setup scroll propagation for nested scrollable containers
* Fixes the issue where scroll gets "trapped" inside .source-articles
* when reaching the top or bottom of the container
*/
setupScrollPropagation() {
// Use event delegation on the feeds container
this.elements.feedsContainer.addEventListener(
"wheel",
e => {
const target = e.target.closest(".source-articles");
if (!target) return;
const { scrollTop, scrollHeight, clientHeight } = target;
const isAtTop = scrollTop <= 0;
const isAtBottom = scrollTop + clientHeight >= scrollHeight - 1;
// If scrolling up and at top, or scrolling down and at bottom,
// let the event propagate to the parent
if ((e.deltaY < 0 && isAtTop) || (e.deltaY > 0 && isAtBottom)) {
// Don't prevent default - let parent scroll
return;
}
// Otherwise, prevent the event from reaching the parent
// This keeps scroll inside the container when not at bounds
if (scrollHeight > clientHeight) {
e.stopPropagation();
}
},
{ passive: true }
);
}
/**
* Setup Back to Top button behavior
*/
setupBackToTop() {
const backToTopBtn = document.getElementById("back-to-top");
if (!backToTopBtn) return;
// Show/hide button based on scroll position
let ticking = false;
window.addEventListener(
"scroll",
() => {
if (!ticking) {
requestAnimationFrame(() => {
const scrollY = window.scrollY || document.documentElement.scrollTop;
// Show button after scrolling 400px
if (scrollY > 400) {
backToTopBtn.classList.add("visible");
} else {
backToTopBtn.classList.remove("visible");
}
ticking = false;
});
ticking = true;
}
},
{ passive: true }
);
// Click handler
backToTopBtn.addEventListener("click", () => {
this.scrollToTop();
});
}
/**
* Smooth scroll to top of page
*/
scrollToTop() {
window.scrollTo({
top: 0,
behavior: "smooth"
});
}
/* ============================================
AUTO-REFRESH SYSTEM
============================================ */
/**
* Start auto-refresh timer
*/
startAutoRefresh() {
// Clear any existing timer
this.stopAutoRefresh();
const intervalMinutes = this.settings.refreshInterval || 10;
const intervalMs = intervalMinutes * 60 * 1000;
// Set next refresh time
this.nextRefreshTime = Date.now() + intervalMs;
// Start main refresh timer
this.autoRefreshTimer = setInterval(() => {
console.log("[RSS] Auto-refresh triggered");
this.refreshFeeds();
// Reset next refresh time
this.nextRefreshTime = Date.now() + intervalMs;
}, intervalMs);
// Start countdown display timer (update every 10 seconds)
this.countdownTimer = setInterval(() => {
this.updateAutoRefreshStatus();
}, 10000);
// Update status immediately
this.updateAutoRefreshStatus();
console.log(`[RSS] Auto-refresh started: every ${intervalMinutes} minutes`);
}
/**
* Stop auto-refresh timer
*/
stopAutoRefresh() {
if (this.autoRefreshTimer) {
clearInterval(this.autoRefreshTimer);
this.autoRefreshTimer = null;
}
if (this.countdownTimer) {
clearInterval(this.countdownTimer);
this.countdownTimer = null;
}
this.nextRefreshTime = null;
this.updateAutoRefreshStatus();
}
/**
* Update auto-refresh status display
*/
updateAutoRefreshStatus() {
const statusEl = document.getElementById("auto-refresh-status");
if (!statusEl) return;
if (!this.settings.autoRefresh || !this.nextRefreshTime) {
statusEl.textContent = "Auto-refresh is disabled";
statusEl.style.color = "";
return;
}
const remaining = this.nextRefreshTime - Date.now();
if (remaining <= 0) {
statusEl.textContent = "Refreshing now...";
statusEl.style.color = "var(--ivy-green)";
return;
}
const minutes = Math.floor(remaining / 60000);
const seconds = Math.floor((remaining % 60000) / 1000);
if (minutes > 0) {
statusEl.textContent = `⏰ Next refresh in ${minutes}m ${seconds}s`;
} else {
statusEl.textContent = `⏰ Next refresh in ${seconds}s`;
}
statusEl.style.color = "var(--ivy-green)";
}
/**
* Set active language filter
*/
setLanguage(lang) {
this.currentLang = lang;
// Update lang buttons
this.elements.langButtons.forEach(btn => {
btn.classList.toggle("active", btn.dataset.lang === lang);
});
// Re-render feeds
this.renderFeeds();
}
/**
* Refresh all feeds
*/
async refreshFeeds() {
// Prevent concurrent refresh calls
if (this.isRefreshing) {
console.debug("[RSS] Refresh already in progress, skipping");
return;
}
this.isRefreshing = true;
try {
// Show loading state
this.elements.btnRefresh.classList.add("spinning");
this.elements.btnRefresh.setAttribute("aria-busy", "true");
this.setStatus("Loading feeds...", "");
this.renderLoading();
// Get enabled feeds
const enabledFeeds = this.feeds.filter(f => f.enabled);
if (enabledFeeds.length === 0) {
this.setStatus("No feeds enabled", "");
this.renderEmpty("No feeds enabled. Go to Settings to enable some feeds.");
return;
}
// Use progressive loading with callback
this.feedResults = [];
await this.parser.fetchAllFeedsProgressive(enabledFeeds, (results, loaded, total) => {
// Update results
this.feedResults = results;
// Calculate stats
const successful = results.filter(r => r.status === "success").length;
const fromCache = results.filter(r => r.fromCache).length;
const maxItems = this.settings.maxItems || 10;
// Count DISPLAYED articles (limited by maxItems per source)
const displayedArticles = results
.filter(r => r.feed)
.reduce((sum, r) => sum + Math.min(r.feed.items.length, maxItems), 0);
// Update status with progress
const cacheInfo = fromCache > 0 ? ` (${fromCache} cached)` : "";
this.setStatus(
`${successful}/${loaded} sources loaded${cacheInfo} • ~${displayedArticles} articles`,
loaded < total ? `Loading ${loaded}/${total}...` : `Last updated: ${this.formatTime(new Date())}`
);
// Render feeds progressively
this.renderFeeds();
});
// Final update
const successful = this.feedResults.filter(r => r.status === "success").length;
const total = this.feedResults.length;
const fromCache = this.feedResults.filter(r => r.fromCache).length;
const maxItems = this.settings.maxItems || 10;
const displayedArticles = this.feedResults
.filter(r => r.feed)
.reduce((sum, r) => sum + Math.min(r.feed.items.length, maxItems), 0);
const cacheInfo = fromCache > 0 ? ` (${fromCache} ⚡)` : "";
this.setStatus(
`${successful}/${total} sources${cacheInfo}${displayedArticles} articles`,
`Last updated: ${this.formatTime(new Date())}`
);
// Render feeds
this.renderFeeds();
// Update sidebar with article data
if (this.sidebar) {
this.sidebar.updateWithArticles(this.feedResults);
}
} catch (error) {
console.error("[RSS] Error during refresh:", error);
this.setStatus("Error loading feeds", "");
this.showToast("Failed to load some feeds. Please try again.", "error");
} finally {
// Always cleanup - stop spinner and reset flag
this.elements.btnRefresh.classList.remove("spinning");
this.elements.btnRefresh.setAttribute("aria-busy", "false");
this.isRefreshing = false;
}
}
/**
* Render loading state
*/
renderLoading() {
const enabledCount = this.feeds.filter(f => f.enabled).length;
this.elements.feedsContainer.innerHTML = `
<div class="loading-state">
<div class="loading-spinner"></div>
<p>Fetching ${enabledCount} feeds...</p>
<p class="hint">First load may take a moment. Shift+R to force refresh.</p>
</div>
`;
}
/**
* Render empty state
*/
renderEmpty(message) {
this.elements.feedsContainer.innerHTML = `
<div class="empty-state">
<div class="empty-icon">📭</div>
<p>${message}</p>
<p class="hint">Try changing filters or enabling more sources in Settings.</p>
</div>
`;
}
/**
* Render feeds to DOM
*/
renderFeeds() {
// Filter by category
let filteredResults = this.feedResults;
if (this.currentCategory !== "all") {
filteredResults = this.feedResults.filter(r => r.category === this.currentCategory);
}
// Filter by language
if (this.currentLang !== "all") {
filteredResults = filteredResults.filter(r => r.lang === this.currentLang);
}
// Filter to only successful feeds
const successfulFeeds = filteredResults.filter(r => r.status === "success" && r.feed);
if (successfulFeeds.length === 0) {
const langLabel = this.currentLang === "en" ? "English" : this.currentLang === "fr" ? "French" : "";
this.renderEmpty(`No articles found for this filter.${langLabel ? ` (${langLabel} only)` : ""}`);
return;
}
// Group by source (default: true)
if (this.settings.groupBySource !== false) {
this.renderGroupedBySource(successfulFeeds);
} else {
this.renderMergedList(successfulFeeds);
}
// Show failed feeds if any (collapsed by default)
const failedFeeds = filteredResults.filter(r => r.status === "error");
if (failedFeeds.length > 0) {
this.renderFailedFeeds(failedFeeds);
}
}
/**
* Render a section showing failed feeds
*/
renderFailedFeeds(failedFeeds) {
const html = `
<section class="source-section error collapsed" data-source="failed-feeds">
<header class="source-header" onclick="app.toggleSource('failed-feeds')" role="button" tabindex="0" aria-expanded="false">
<div class="source-title">
<span class="source-icon">⚠️</span>
<span class="source-name">Failed to load (${failedFeeds.length})</span>
</div>
<div class="source-meta">
<span class="source-count" style="background: #ef4444;">${failedFeeds.length}</span>
<span class="source-toggle">▼</span>
</div>
</header>
<ul class="source-articles">
${failedFeeds
.map(
feed => `
<div class="article-item-wrapper">
<div class="article-item" style="cursor: default;">
<div class="article-title">${this.escapeHtml(feed.name)}</div>
<div class="article-meta">
<span style="color: #ef4444;">❌ ${this.escapeHtml(feed.error || "Unknown error")}</span>
</div>
</div>
</div>
`
)
.join("")}
</ul>
</section>
`;
this.elements.feedsContainer.insertAdjacentHTML("beforeend", html);
}
/**
* Render feeds grouped by source (optimized with innerHTML batching)
*/
renderGroupedBySource(feedResults) {
const maxItems = this.settings.maxItems || 10;
// Build all HTML at once for better performance (single DOM reflow)
const htmlParts = feedResults.map((result, index) => {
const items = result.feed.items.slice(0, maxItems);
const categoryInfo = window.FeedsConfig.CATEGORIES[result.category] || {};
const isFavorite = this.sidebar?.isSourceFavorite(result.id);
const favoriteClass = isFavorite ? "saved" : "";
const colorIndex = this.getSourceColorIndex(result.id);
// Determine status class based on result
const statusClass = result.fromCache ? "cached" : "fresh";
const statusIcon = result.fromCache ? "⚡" : "";
return `
<section class="source-section" data-source="${result.id}" data-color-index="${colorIndex}" role="region" aria-label="${this.escapeHtml(result.name)} feed">
<header class="source-header" role="button" tabindex="0" aria-expanded="true" onclick="app.toggleSource('${result.id}')" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();app.toggleSource('${result.id}')}">
<button class="source-favorite ${favoriteClass}"
onclick="event.stopPropagation(); app.toggleSourceFavorite('${result.id}', '${this.escapeHtml(result.name)}', '${result.icon}')"
onkeydown="event.stopPropagation()"
aria-label="${isFavorite ? "Remove from favorites" : "Add to favorites"}"
title="${isFavorite ? "Remove from favorites" : "Add to favorites"}">
${isFavorite ? "★" : "☆"}
</button>
<div class="source-title">
<span class="source-icon" aria-hidden="true">${result.icon}</span>
<span class="source-name">${result.name}</span>
</div>
<div class="source-meta">
<span class="source-count" aria-label="${items.length} articles">${items.length}</span>
<span class="source-status ${statusClass}" title="${result.fromCache ? "Loaded from cache" : "Fresh from source"}">
${statusIcon} ${this.formatTimeAgo(result.lastFetched)}
</span>
<span class="source-toggle" aria-hidden="true">▼</span>
</div>
</header>
<ul class="source-articles">
${items.map(item => this.renderArticleItem(item)).join("")}
</ul>
</section>
`;
});
// Single DOM update for better performance
this.elements.feedsContainer.innerHTML = htmlParts.join("");
}
/**
* Toggle favorite state for a source
*/
toggleSourceFavorite(sourceId, sourceName, sourceIcon) {
if (!this.sidebar) return;
const wasAdded = this.sidebar.toggleFavoriteSource(sourceId, sourceName, sourceIcon);
this.showToast(
wasAdded ? `${sourceName} added to favorites! ⭐` : `${sourceName} removed from favorites`,
wasAdded ? "success" : "info"
);
this.renderFeeds();
}
/**
* Render all articles in a merged list sorted by date
*/
renderMergedList(feedResults) {
const maxItems = this.settings.maxItems || 10;
// Collect all articles with source info
const allArticles = feedResults.flatMap(result =>
result.feed.items.map(item => ({
...item,
sourceName: result.name,
sourceIcon: result.icon,
sourceId: result.id
}))
);
// Sort by date (newest first)
allArticles.sort((a, b) => {
const dateA = a.pubDate || new Date(0);
const dateB = b.pubDate || new Date(0);
return dateB - dateA;
});
// Limit total
const limitedArticles = allArticles.slice(0, maxItems * feedResults.length);
const html = `
<section class="source-section">
<header class="source-header">
<div class="source-title">
<span class="source-icon">📰</span>
<span class="source-name">All Articles</span>
</div>
<div class="source-meta">
<span class="source-count">${limitedArticles.length}</span>
</div>
</header>
<ul class="source-articles">
${limitedArticles.map(item => this.renderArticleItem(item, true)).join("")}
</ul>
</section>
`;
this.elements.feedsContainer.innerHTML = html;
}
/**
* Render a single article item
*/
renderArticleItem(item, showSource = false) {
const dateStr = item.pubDate ? this.formatTimeAgo(item.pubDate) : "";
const fullDate = item.pubDate ? this.formatFullDate(item.pubDate) : "";
const sourceStr =
showSource && item.sourceName
? `<span class="article-source">${item.sourceIcon} ${item.sourceName}</span>`
: "";
// Check if article is recent (less than 2 hours old)
const isRecent = item.pubDate && Date.now() - item.pubDate.getTime() < 2 * 60 * 60 * 1000;
const recentClass = isRecent ? "recent" : "";
// Show description preview if available and enabled in settings
const showDescriptions = this.settings.showDescriptions !== false;
const descriptionStr =
showDescriptions && item.description
? `<div class="article-description">${this.escapeHtml(item.description.substring(0, 120))}${item.description.length > 120 ? "..." : ""}</div>`
: "";
const isBookmarked = this.sidebar?.isBookmarked(item.link);
const bookmarkClass = isBookmarked ? "saved" : "";
// Use data attributes to avoid XSS and JSON parsing issues in onclick
const safeTitle = this.escapeAttr(item.title);
const safeLink = this.escapeAttr(item.link);
const safeSource = this.escapeAttr(item.sourceName || "Unknown");
return `
<div class="article-item-wrapper ${recentClass}">
<a class="article-item" href="${this.escapeHtml(item.link)}" target="_blank" rel="noopener">
<div class="article-title">${isRecent ? '<span class="new-badge">NEW</span>' : ""}${this.escapeHtml(item.title)}</div>
${descriptionStr}
<div class="article-meta">
${sourceStr}
${dateStr ? `<span class="article-date" title="${fullDate}">🕐 ${dateStr}</span>` : ""}
</div>
</a>
<button class="article-bookmark ${bookmarkClass}"
data-title="${safeTitle}"
data-link="${safeLink}"
data-source="${safeSource}"
onclick="app.toggleBookmarkFromElement(this)"
aria-label="${isBookmarked ? "Remove bookmark" : "Add bookmark"}"
title="${isBookmarked ? "Remove bookmark" : "Add bookmark"}">
${isBookmarked ? "★" : "☆"}
</button>
</div>
`;
}
/**
* Toggle bookmark for an article (from data attributes)
*/
toggleBookmarkFromElement(element) {
if (!this.sidebar) return;
const articleData = {
title: element.dataset.title,
link: element.dataset.link,
sourceName: element.dataset.source
};
if (this.sidebar.isBookmarked(articleData.link)) {
this.sidebar.removeBookmark(articleData.link);
this.showToast("Bookmark removed", "info");
} else {
this.sidebar.addBookmark(articleData);
this.showToast("Bookmark added! ⭐", "success");
}
// Re-render to update bookmark states
this.renderFeeds();
}
/**
* Show toast notification
*/
showToast(message, type = "info") {
// Remove existing toast if any
const existing = document.querySelector(".toast-notification");
if (existing) existing.remove();
const toast = document.createElement("div");
toast.className = `toast-notification toast-${type}`;
toast.setAttribute("role", "status");
toast.setAttribute("aria-live", "polite");
toast.innerHTML = `<span>${message}</span>`;
document.body.appendChild(toast);
// Trigger animation
requestAnimationFrame(() => toast.classList.add("show"));
// Auto-dismiss
setTimeout(() => {
toast.classList.remove("show");
setTimeout(() => toast.remove(), 300);
}, 2500);
}
/**
* Toggle source section collapse
*/
toggleSource(sourceId) {
const section = document.querySelector(`.source-section[data-source="${sourceId}"]`);
if (section) {
section.classList.toggle("collapsed");
// Update aria-expanded
const header = section.querySelector(".source-header");
if (header) {
const isCollapsed = section.classList.contains("collapsed");
header.setAttribute("aria-expanded", !isCollapsed);
}
}
}
/**
* Render sources list in settings
*/
renderSourcesList() {
const html = this.feeds
.map(feed => {
const categoryInfo = window.FeedsConfig.CATEGORIES[feed.category] || {};
const langFlag = feed.lang === "fr" ? "🇫🇷" : feed.lang === "en" ? "🇬🇧" : "🌍";
const deleteBtn = feed.custom
? `<button class="source-delete-btn" onclick="event.stopPropagation(); app.deleteCustomFeed('${feed.id}')" title="Delete custom feed">🗑️</button>`
: "";
return `
<div class="source-toggle-item" data-feed-id="${feed.id}">
<input type="checkbox"
id="feed-${feed.id}"
${feed.enabled ? "checked" : ""}
onchange="app.toggleFeedEnabled('${feed.id}', this.checked)"
aria-label="Enable ${this.escapeHtml(feed.name)}">
<div class="source-toggle-info">
<div class="source-toggle-name">${feed.icon} ${this.escapeHtml(feed.name)} ${feed.custom ? "<span class='custom-badge'>Custom</span>" : ""}</div>
<div class="source-toggle-url">${this.escapeHtml(feed.url)}</div>
</div>
<span class="source-toggle-lang" title="${feed.lang === "fr" ? "French" : "English"}">${langFlag}</span>
<span class="source-toggle-category">${categoryInfo.icon || ""} ${categoryInfo.name || feed.category}</span>
${deleteBtn}
</div>
`;
})
.join("");
this.elements.sourcesList.innerHTML = html;
}
/**
* Delete a custom feed
*/
deleteCustomFeed(feedId) {
const feed = this.feeds.find(f => f.id === feedId);
if (!feed || !feed.custom) {
this.showToast("Cannot delete built-in feeds", "error");
return;
}
if (!confirm(`Delete "${feed.name}"?`)) return;
this.feeds = this.feeds.filter(f => f.id !== feedId);
this.saveSettings();
this.renderSourcesList();
this.showToast(`"${feed.name}" deleted`, "info");
this.refreshFeeds();
}
/**
* Toggle feed enabled state
*/
toggleFeedEnabled(feedId, enabled) {
const feed = this.feeds.find(f => f.id === feedId);
if (feed) {
feed.enabled = enabled;
this.saveSettings();
this.showToast(`${feed.name} ${enabled ? "enabled" : "disabled"}`, enabled ? "success" : "info");
}
}
/**
* Add custom feed with validation
*/
async addCustomFeed() {
const nameInput = document.getElementById("custom-feed-name");
const urlInput = document.getElementById("custom-feed-url");
const categorySelect = document.getElementById("custom-feed-category");
const addBtn = document.getElementById("btn-add-feed");
const name = nameInput.value.trim();
const url = urlInput.value.trim();
const category = categorySelect.value;
if (!name || !url) {
this.showToast("Please enter both name and URL", "error");
return;
}
// Validate URL format
try {
new URL(url);
} catch {
this.showToast("Please enter a valid URL", "error");
return;
}
// Show loading state
const originalText = addBtn.textContent;
addBtn.textContent = "Testing...";
addBtn.disabled = true;
// Test if the URL is actually a valid RSS feed
try {
const result = await this.parser.fetchFeed({ url, name, id: "test" });
if (result.status !== "success") {
throw new Error(result.error || "Invalid RSS feed");
}
} catch (e) {
addBtn.textContent = originalText;
addBtn.disabled = false;
this.showToast(`Invalid feed: ${e.message}`, "error");
return;
}
// Restore button
addBtn.textContent = originalText;
addBtn.disabled = false;
// Generate unique ID
const id = "custom_" + Date.now();
// Detect language from category or default to 'en'
const langSelect = document.getElementById("custom-feed-lang");
const lang = langSelect ? langSelect.value : "en";
// Add to feeds
this.feeds.push({
id,
name,
url,
icon: "📡",
category,
lang,
enabled: true,
custom: true
});
// Save and refresh
this.saveSettings();
this.renderSourcesList();
this.showToast(`Feed "${name}" added! 🎉`, "success");
// Clear inputs
nameInput.value = "";
urlInput.value = "";
// Refresh feeds
this.refreshFeeds();
}
/**
* Load settings from localStorage
*/
loadSettings() {
try {
const saved = localStorage.getItem("ivy-rss-hub-settings");
if (saved) {
const parsed = JSON.parse(saved);
// Ensure groupBySource defaults to true if not set
if (parsed.groupBySource === undefined) {
parsed.groupBySource = true;
}
// Ensure showDescriptions defaults to true if not set
if (parsed.showDescriptions === undefined) {
parsed.showDescriptions = true;
}
// Run migrations for existing users
if (parsed.feeds) {
parsed.feeds = this.migrateFeeds(parsed.feeds);
}
return parsed;
}
} catch (e) {
console.warn("Failed to load settings:", e);
}
// Defaults
return {
groupBySource: true,
showDescriptions: true,
maxItems: 10,
feeds: null // Will use defaults
};
}
/**
* Migrate old feed configurations to new ones
* This handles breaking changes like the arXiv RSS -> API migration
*/
migrateFeeds(feeds) {
// Migration v1: Replace old arXiv RSS feeds with new API feed
// Old feeds: arxiv-ai, arxiv-ml, arxiv-cl, arxiv-cv, arxiv-ne, arxiv-combined
// New feed: arxiv-ai-api
const oldArxivIds = ["arxiv-ai", "arxiv-ml", "arxiv-cl", "arxiv-cv", "arxiv-ne", "arxiv-combined"];
const newArxivId = "arxiv-ai-api";
const hasOldArxiv = feeds.some(f => oldArxivIds.includes(f.id));
const hasNewArxiv = feeds.some(f => f.id === newArxivId);
if (hasOldArxiv && !hasNewArxiv) {
console.log("[Migration] Replacing old arXiv RSS feeds with new API feed");
// Check if any old arXiv feed was enabled
const wasEnabled = feeds.some(f => oldArxivIds.includes(f.id) && f.enabled);
// Remove old arXiv feeds
feeds = feeds.filter(f => !oldArxivIds.includes(f.id));
// Add new combined API feed at the beginning of AI category
const newArxivFeed = {
id: "arxiv-ai-api",
name: "arXiv — AI & ML (Recent)",
url: "https://export.arxiv.org/api/query?search_query=cat:cs.AI+OR+cat:cs.LG+OR+cat:cs.CL+OR+cat:cs.CV+OR+cat:cs.NE&max_results=100&sortBy=submittedDate&sortOrder=descending",
icon: "🎓",
category: "ai",
lang: "en",
enabled: wasEnabled,
isAtom: true
};
// Insert at the beginning
feeds.unshift(newArxivFeed);
// Mark that we need to save after init
this._needsMigrationSave = true;
}
return feeds;
}
/**
* Reset all data (localStorage + IndexedDB)
*/
async resetAllData() {
if (
!confirm(
"Are you sure you want to reset ALL data?\n\nThis will delete:\n• All settings\n• Custom feeds\n• Bookmarks\n• Cached data\n\nThis action cannot be undone."
)
) {
return;
}
try {
// Clear localStorage entries for this app
localStorage.removeItem("ivy-rss-hub-settings");
localStorage.removeItem("ivy-rss-hub-theme");
localStorage.removeItem("ivy-rss-hub-bookmarks");
localStorage.removeItem("ivy-rss-hub-favorites");
localStorage.removeItem("ivy-rss-hub-sidebar");
// Clear IndexedDB (Dexie cache)
if (this.parser && this.parser.db) {
await this.parser.db.delete();
console.log("🗑️ IndexedDB cleared");
}
this.showToast("All data cleared! Reloading...", "success");
// Reload page after a short delay
setTimeout(() => {
window.location.reload();
}, 1000);
} catch (e) {
console.error("Reset failed:", e);
this.showToast("Reset failed: " + e.message, "error");
}
}
/**
* Save settings to localStorage with quota handling
*/
saveSettings() {
try {
this.settings.feeds = this.feeds;
const data = JSON.stringify(this.settings);
// Check approximate size (rough estimate)
const sizeKB = new Blob([data]).size / 1024;
if (sizeKB > 4500) {
// Approaching 5MB limit - warn user
console.warn(`Settings size: ${sizeKB.toFixed(1)}KB - approaching limit`);
this.showToast("Storage nearly full. Consider removing some feeds.", "error");
}
localStorage.setItem("ivy-rss-hub-settings", data);
} catch (e) {
if (e.name === "QuotaExceededError") {
this.showToast("Storage full! Please delete some feeds.", "error");
}
console.warn("Failed to save settings:", e);
}
}
/**
* Set status bar text
*/
setStatus(text, time) {
this.elements.statusText.textContent = text;
this.elements.statusTime.textContent = time;
}
/**
* Format time to readable string
*/
formatTime(date) {
return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
/**
* Format full date and time for tooltips
*/
formatFullDate(date) {
if (!date) return "";
return date.toLocaleString("fr-FR", {
weekday: "short",
day: "numeric",
month: "short",
year: "numeric",
hour: "2-digit",
minute: "2-digit"
});
}
/**
* Format time ago string
*/
formatTimeAgo(date) {
if (!date) return "";
const now = new Date();
const diffMs = now - date;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return date.toLocaleDateString();
}
/**
* Escape HTML to prevent XSS (delegates to shared utility)
*/
escapeHtml(text) {
return window.IvyUtils.escapeHtml(text);
}
/**
* Escape text for use in HTML attributes (delegates to shared utility)
*/
escapeAttr(text) {
return window.IvyUtils.escapeAttr(text);
}
/**
* Get a consistent color index (0-11) based on source ID
* Uses simple hash for consistent colors per source
*/
getSourceColorIndex(sourceId) {
let hash = 0;
for (let i = 0; i < sourceId.length; i++) {
hash = (hash << 5) - hash + sourceId.charCodeAt(i);
hash = hash & hash; // Convert to 32-bit integer
}
return Math.abs(hash) % 12; // 12 color variations
}
}
// Initialize app when DOM is ready
let app;
document.addEventListener("DOMContentLoaded", () => {
app = new RSSHubApp();
window.app = app; // Expose for event handlers
});