Spaces:
Running
Running
| /* ============================================ | |
| 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, "<") | |
| .replace(/>/g, ">"); | |
| }, | |
| /** | |
| * 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 | |
| }); | |