Spaces:
Running
Running
| /* ============================================ | |
| IVY'S RSS HUB — Sidebar Module | |
| Search, Bookmarks, Trending, Favorites, Calendar | |
| ============================================ */ | |
| /** | |
| * SidebarManager - Handles all sidebar functionality | |
| */ | |
| class SidebarManager { | |
| constructor(app) { | |
| this.app = app; | |
| // State | |
| this.bookmarks = this.loadBookmarks(); | |
| this.favoriteSources = this.loadFavorites(); | |
| this.allArticles = []; | |
| this.searchDebounceTimer = null; | |
| this.trendingCache = null; // Cache for trending topics | |
| this.trendingCacheKey = null; // Cache key based on article count | |
| // DOM Elements | |
| this.elements = { | |
| sidebar: document.getElementById("sidebar"), | |
| sidebarToggle: document.getElementById("sidebar-toggle"), | |
| // Search | |
| searchInput: document.getElementById("search-input"), | |
| searchClear: document.getElementById("search-clear"), | |
| searchResults: document.getElementById("search-results"), | |
| // Bookmarks | |
| bookmarksList: document.getElementById("bookmarks-list"), | |
| btnClearBookmarks: document.getElementById("btn-clear-bookmarks"), | |
| // Trending | |
| trendingTags: document.getElementById("trending-tags"), | |
| // Favorites | |
| favoritesList: document.getElementById("favorites-list"), | |
| // Calendar | |
| calendarGrid: document.getElementById("calendar-grid") | |
| }; | |
| this.init(); | |
| } | |
| /** | |
| * Initialize sidebar | |
| */ | |
| init() { | |
| this.setupEventListeners(); | |
| this.setupCollapsibleSections(); | |
| this.renderBookmarks(); | |
| this.renderFavorites(); | |
| this.renderCalendar(); | |
| } | |
| /** | |
| * Cleanup method (call on app destroy if needed) | |
| */ | |
| destroy() { | |
| if (this.searchDebounceTimer) { | |
| clearTimeout(this.searchDebounceTimer); | |
| this.searchDebounceTimer = null; | |
| } | |
| } | |
| /** | |
| * Setup event listeners | |
| */ | |
| setupEventListeners() { | |
| // Sidebar toggle (mobile) | |
| this.elements.sidebarToggle?.addEventListener("click", () => { | |
| this.elements.sidebar.classList.toggle("open"); | |
| }); | |
| // Close sidebar when clicking outside (mobile) | |
| document.addEventListener("click", e => { | |
| if (window.innerWidth <= 1100) { | |
| if ( | |
| !this.elements.sidebar.contains(e.target) && | |
| !this.elements.sidebarToggle.contains(e.target) && | |
| this.elements.sidebar.classList.contains("open") | |
| ) { | |
| this.elements.sidebar.classList.remove("open"); | |
| } | |
| } | |
| }); | |
| // Search with debounce for performance | |
| this.elements.searchInput?.addEventListener("input", e => { | |
| clearTimeout(this.searchDebounceTimer); | |
| this.searchDebounceTimer = setTimeout(() => { | |
| this.handleSearch(e.target.value); | |
| }, 150); | |
| }); | |
| this.elements.searchClear?.addEventListener("click", () => { | |
| this.elements.searchInput.value = ""; | |
| this.handleSearch(""); | |
| }); | |
| // Clear bookmarks with double-click protection | |
| this.elements.btnClearBookmarks?.addEventListener("click", e => { | |
| // Use data attribute for confirmation state | |
| if (e.target.dataset.confirmClear === "true") { | |
| this.bookmarks = []; | |
| this.saveBookmarks(); | |
| this.renderBookmarks(); | |
| e.target.textContent = "Clear All"; | |
| e.target.dataset.confirmClear = "false"; | |
| e.target.setAttribute("aria-label", "Clear all bookmarks"); | |
| // Refresh main feed to update bookmark icons | |
| if (this.app) this.app.renderFeeds(); | |
| this.app?.showToast("All bookmarks cleared", "info"); | |
| } else { | |
| e.target.textContent = "Click again to confirm"; | |
| e.target.dataset.confirmClear = "true"; | |
| e.target.setAttribute("aria-label", "Click again to confirm clearing all bookmarks"); | |
| // Reset after 3 seconds | |
| setTimeout(() => { | |
| e.target.textContent = "Clear All"; | |
| e.target.dataset.confirmClear = "false"; | |
| e.target.setAttribute("aria-label", "Clear all bookmarks"); | |
| }, 3000); | |
| } | |
| }); | |
| } | |
| /** | |
| * Setup collapsible sections | |
| */ | |
| setupCollapsibleSections() { | |
| document.querySelectorAll(".sidebar-title.collapsible").forEach(title => { | |
| title.addEventListener("click", () => { | |
| const targetId = title.dataset.target; | |
| const content = document.getElementById(targetId); | |
| if (content) { | |
| title.classList.toggle("collapsed"); | |
| content.classList.toggle("collapsed"); | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Update sidebar with new article data | |
| */ | |
| updateWithArticles(feedResults) { | |
| // Collect all articles | |
| this.allArticles = feedResults | |
| .filter(r => r.status === "success" && r.feed) | |
| .flatMap(result => | |
| result.feed.items.map(item => ({ | |
| ...item, | |
| sourceName: result.name, | |
| sourceIcon: result.icon, | |
| sourceId: result.id | |
| })) | |
| ); | |
| // Update components | |
| this.renderTrendingTopics(); | |
| this.renderCalendar(); | |
| this.renderFavorites(); | |
| } | |
| /* ============================================ | |
| SEARCH | |
| ============================================ */ | |
| handleSearch(query) { | |
| // Clear any pending debounce | |
| if (this.searchDebounceTimer) { | |
| clearTimeout(this.searchDebounceTimer); | |
| this.searchDebounceTimer = null; | |
| } | |
| if (!query || query.length < 2) { | |
| this.elements.searchResults.innerHTML = | |
| '<span class="search-hint">Type at least 2 characters to search...</span>'; | |
| return; | |
| } | |
| // Show loading indicator immediately for better UX | |
| this.elements.searchResults.innerHTML = | |
| '<span class="search-hint"><span class="search-spinner"></span> Searching...</span>'; | |
| // Guard against empty allArticles | |
| if (!this.allArticles || this.allArticles.length === 0) { | |
| this.elements.searchResults.innerHTML = | |
| '<span class="search-hint">No articles loaded yet. Please wait...</span>'; | |
| return; | |
| } | |
| const queryLower = query.toLowerCase(); | |
| const results = this.allArticles | |
| .filter(article => article.title && article.title.toLowerCase().includes(queryLower)) | |
| .slice(0, 20); | |
| if (results.length === 0) { | |
| this.elements.searchResults.innerHTML = | |
| '<span class="search-hint">No results found for "' + this.escapeHtml(query) + '"</span>'; | |
| return; | |
| } | |
| // Show count in results header | |
| const countText = | |
| results.length === 20 ? "20+ results" : `${results.length} result${results.length > 1 ? "s" : ""}`; | |
| this.elements.searchResults.innerHTML = | |
| `<div class="search-count">${countText} for "${this.escapeHtml(query)}"</div>` + | |
| results | |
| .map( | |
| article => ` | |
| <a class="search-result-item" href="${this.escapeHtml(article.link)}" target="_blank" rel="noopener"> | |
| ${this.highlightMatch(article.title, query)} | |
| <div class="search-result-source">${article.sourceIcon} ${this.escapeHtml(article.sourceName)}</div> | |
| </a> | |
| ` | |
| ) | |
| .join(""); | |
| } | |
| highlightMatch(text, query) { | |
| // First escape the text for HTML display | |
| const escaped = this.escapeHtml(text); | |
| // Escape the query for use in regex (not HTML escape, regex escape) | |
| const regexSafeQuery = this.escapeRegex(query); | |
| // Also need to escape HTML entities in the escaped text for matching | |
| const escapedQueryForMatch = this.escapeHtml(query); | |
| const regexSafeEscapedQuery = this.escapeRegex(escapedQueryForMatch); | |
| const regex = new RegExp(`(${regexSafeEscapedQuery})`, "gi"); | |
| return escaped.replace(regex, "<mark>$1</mark>"); | |
| } | |
| escapeRegex(str) { | |
| return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); | |
| } | |
| /* ============================================ | |
| BOOKMARKS | |
| ============================================ */ | |
| loadBookmarks() { | |
| try { | |
| return JSON.parse(localStorage.getItem("ivy-rss-bookmarks") || "[]"); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| saveBookmarks() { | |
| localStorage.setItem("ivy-rss-bookmarks", JSON.stringify(this.bookmarks)); | |
| } | |
| addBookmark(article) { | |
| // Check if already bookmarked | |
| if (this.bookmarks.find(b => b.link === article.link)) { | |
| return false; | |
| } | |
| this.bookmarks.unshift({ | |
| title: article.title, | |
| link: article.link, | |
| source: article.sourceName || "Unknown", | |
| savedAt: new Date().toISOString() | |
| }); | |
| // Limit to 50 bookmarks | |
| if (this.bookmarks.length > 50) { | |
| this.bookmarks = this.bookmarks.slice(0, 50); | |
| } | |
| this.saveBookmarks(); | |
| this.renderBookmarks(); | |
| return true; | |
| } | |
| removeBookmark(link) { | |
| this.bookmarks = this.bookmarks.filter(b => b.link !== link); | |
| this.saveBookmarks(); | |
| this.renderBookmarks(); | |
| } | |
| isBookmarked(link) { | |
| return this.bookmarks.some(b => b.link === link); | |
| } | |
| renderBookmarks() { | |
| // Update bookmark count badge | |
| const countBadge = document.getElementById("bookmark-count"); | |
| if (countBadge) { | |
| countBadge.textContent = this.bookmarks.length > 0 ? `(${this.bookmarks.length})` : ""; | |
| } | |
| if (this.bookmarks.length === 0) { | |
| this.elements.bookmarksList.innerHTML = | |
| '<span class="empty-hint">No bookmarks yet. Click ⭐ on articles to save them.</span>'; | |
| return; | |
| } | |
| this.elements.bookmarksList.innerHTML = this.bookmarks | |
| .map( | |
| bookmark => ` | |
| <div class="bookmark-item"> | |
| <a class="bookmark-link" href="${this.escapeHtml(bookmark.link)}" target="_blank" rel="noopener"> | |
| ${this.escapeHtml(this.truncate(bookmark.title, 60))} | |
| </a> | |
| <button class="bookmark-remove" data-link="${this.escapeAttr(bookmark.link)}" onclick="sidebar.removeBookmarkFromElement(this)" title="Remove" aria-label="Remove bookmark">×</button> | |
| </div> | |
| ` | |
| ) | |
| .join(""); | |
| } | |
| /** | |
| * Remove bookmark from button element (uses data-link) | |
| */ | |
| removeBookmarkFromElement(element) { | |
| const link = element.dataset.link; | |
| if (link) { | |
| this.removeBookmark(link); | |
| // Refresh main feed view to update star icons | |
| if (this.app) this.app.renderFeeds(); | |
| } | |
| } | |
| /* ============================================ | |
| TRENDING TOPICS | |
| ============================================ */ | |
| renderTrendingTopics() { | |
| // Check cache - only recalculate if article count changed | |
| const cacheKey = this.allArticles.length; | |
| if (this.trendingCache && this.trendingCacheKey === cacheKey) { | |
| this.elements.trendingTags.innerHTML = this.trendingCache; | |
| return; | |
| } | |
| // Extract keywords from titles | |
| const wordCounts = {}; | |
| const stopWords = new Set([ | |
| "the", | |
| "a", | |
| "an", | |
| "and", | |
| "or", | |
| "but", | |
| "in", | |
| "on", | |
| "at", | |
| "to", | |
| "for", | |
| "of", | |
| "with", | |
| "by", | |
| "from", | |
| "is", | |
| "are", | |
| "was", | |
| "were", | |
| "be", | |
| "been", | |
| "have", | |
| "has", | |
| "had", | |
| "do", | |
| "does", | |
| "did", | |
| "will", | |
| "would", | |
| "could", | |
| "should", | |
| "may", | |
| "might", | |
| "must", | |
| "can", | |
| "this", | |
| "that", | |
| "these", | |
| "those", | |
| "it", | |
| "its", | |
| "as", | |
| "if", | |
| "when", | |
| "where", | |
| "how", | |
| "what", | |
| "which", | |
| "who", | |
| "whom", | |
| "why", | |
| "not", | |
| "no", | |
| "yes", | |
| "all", | |
| "any", | |
| "both", | |
| "each", | |
| "few", | |
| "more", | |
| "most", | |
| "other", | |
| "some", | |
| "such", | |
| "than", | |
| "too", | |
| "very", | |
| "just", | |
| "also", | |
| "now", | |
| "new", | |
| "like", | |
| "your", | |
| "you", | |
| "we", | |
| "they", | |
| "he", | |
| "she", | |
| "his", | |
| "her", | |
| "their", | |
| "our", | |
| "le", | |
| "la", | |
| "les", | |
| "de", | |
| "du", | |
| "des", | |
| "un", | |
| "une", | |
| "et", | |
| "ou", | |
| "pour", | |
| "avec", | |
| "sur", | |
| "dans", | |
| "par", | |
| "plus", | |
| "que", | |
| "qui", | |
| "est", | |
| "son", | |
| "sa", | |
| "ses", | |
| "ce", | |
| "cette", | |
| "ces", | |
| "en", | |
| "au", | |
| "aux", | |
| "ne", | |
| "pas", | |
| "se", | |
| "si", | |
| "il", | |
| "elle", | |
| "ils", | |
| "nous", | |
| "vous", | |
| "être", | |
| "avoir", | |
| "fait", | |
| "faire", | |
| "après", | |
| "avant", | |
| "tout", | |
| "tous", | |
| "comment" | |
| ]); | |
| this.allArticles.forEach(article => { | |
| const words = article.title | |
| .toLowerCase() | |
| .replace(/[^\w\sàâäéèêëïîôùûüç-]/g, " ") | |
| .split(/\s+/) | |
| .filter(word => word.length > 3 && !stopWords.has(word) && !/^\d+$/.test(word)); | |
| words.forEach(word => { | |
| wordCounts[word] = (wordCounts[word] || 0) + 1; | |
| }); | |
| }); | |
| // Sort by count and take top 15 | |
| const trending = Object.entries(wordCounts) | |
| .sort((a, b) => b[1] - a[1]) | |
| .slice(0, 15); | |
| if (trending.length === 0) { | |
| this.elements.trendingTags.innerHTML = '<span class="empty-hint">No trending topics yet.</span>'; | |
| return; | |
| } | |
| const maxCount = trending[0][1]; | |
| const html = trending | |
| .map(([word, count]) => { | |
| const isHot = count >= maxCount * 0.7; | |
| return ` | |
| <button class="trending-tag ${isHot ? "hot" : ""}" | |
| onclick="sidebar.filterByTag('${this.escapeHtml(word)}')" | |
| onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sidebar.filterByTag('${this.escapeHtml(word)}')}" | |
| tabindex="0" | |
| role="button" | |
| aria-label="Filter by ${this.escapeHtml(word)}, ${count} articles"> | |
| ${word} | |
| <span class="tag-count" aria-hidden="true">${count}</span> | |
| </button> | |
| `; | |
| }) | |
| .join(""); | |
| // Cache the result | |
| this.trendingCache = html; | |
| this.trendingCacheKey = cacheKey; | |
| this.elements.trendingTags.innerHTML = html; | |
| } | |
| filterByTag(tag) { | |
| this.elements.searchInput.value = tag; | |
| this.handleSearch(tag); | |
| } | |
| /* ============================================ | |
| FAVORITE SOURCES | |
| ============================================ */ | |
| loadFavorites() { | |
| try { | |
| return JSON.parse(localStorage.getItem("ivy-rss-favorites") || "[]"); | |
| } catch { | |
| return []; | |
| } | |
| } | |
| saveFavorites() { | |
| localStorage.setItem("ivy-rss-favorites", JSON.stringify(this.favoriteSources)); | |
| } | |
| toggleFavoriteSource(sourceId, sourceName, sourceIcon) { | |
| const index = this.favoriteSources.findIndex(f => f.id === sourceId); | |
| if (index >= 0) { | |
| this.favoriteSources.splice(index, 1); | |
| } else { | |
| this.favoriteSources.push({ | |
| id: sourceId, | |
| name: sourceName, | |
| icon: sourceIcon | |
| }); | |
| } | |
| this.saveFavorites(); | |
| this.renderFavorites(); | |
| return index < 0; // Returns true if added | |
| } | |
| isSourceFavorite(sourceId) { | |
| return this.favoriteSources.some(f => f.id === sourceId); | |
| } | |
| renderFavorites() { | |
| if (this.favoriteSources.length === 0) { | |
| this.elements.favoritesList.innerHTML = | |
| '<span class="empty-hint">No favorite sources. Click ⭐ on source headers.</span>'; | |
| return; | |
| } | |
| // Get article counts per source | |
| const sourceCounts = {}; | |
| this.allArticles.forEach(article => { | |
| sourceCounts[article.sourceId] = (sourceCounts[article.sourceId] || 0) + 1; | |
| }); | |
| this.elements.favoritesList.innerHTML = this.favoriteSources | |
| .map( | |
| source => ` | |
| <div class="favorite-source" onclick="sidebar.scrollToSource('${source.id}')" role="button" tabindex="0" onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sidebar.scrollToSource('${source.id}')}"> | |
| <span class="favorite-icon" aria-hidden="true">${source.icon}</span> | |
| <span class="favorite-name">${this.escapeHtml(source.name)}</span> | |
| <span class="favorite-count" aria-label="${sourceCounts[source.id] || 0} articles">${sourceCounts[source.id] || 0}</span> | |
| <button class="favorite-remove" onclick="event.stopPropagation(); sidebar.toggleFavoriteSource('${source.id}')" title="Remove from favorites" aria-label="Remove ${this.escapeHtml(source.name)} from favorites">×</button> | |
| </div> | |
| ` | |
| ) | |
| .join(""); | |
| } | |
| scrollToSource(sourceId) { | |
| const section = document.querySelector(`.source-section[data-source="${sourceId}"]`); | |
| if (section) { | |
| section.scrollIntoView({ behavior: "smooth", block: "start" }); | |
| // Flash effect | |
| section.style.boxShadow = "0 0 0 2px var(--ivy-green)"; | |
| setTimeout(() => { | |
| section.style.boxShadow = ""; | |
| }, 1500); | |
| } | |
| // Close sidebar on mobile | |
| if (window.innerWidth <= 1100) { | |
| this.elements.sidebar.classList.remove("open"); | |
| } | |
| } | |
| /* ============================================ | |
| CALENDAR | |
| ============================================ */ | |
| renderCalendar() { | |
| const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); // Normalize to midnight for consistent comparison | |
| const dayCounts = {}; | |
| // Initialize count for last 7 days | |
| for (let i = 0; i < 7; i++) { | |
| const date = new Date(today); | |
| date.setDate(date.getDate() - i); | |
| const dateKey = this.getDateKey(date); | |
| dayCounts[dateKey] = 0; | |
| } | |
| // Count articles per day | |
| this.allArticles.forEach(article => { | |
| if (article.pubDate) { | |
| const dateKey = this.getDateKey(article.pubDate); | |
| if (dateKey in dayCounts) { | |
| dayCounts[dateKey]++; | |
| } | |
| } | |
| }); | |
| // Build calendar HTML (last 7 days) | |
| const calendarHtml = []; | |
| for (let i = 6; i >= 0; i--) { | |
| const date = new Date(today); | |
| date.setDate(date.getDate() - i); | |
| const dateKey = this.getDateKey(date); | |
| const count = dayCounts[dateKey] || 0; | |
| const isToday = i === 0; | |
| const dayName = days[date.getDay()]; | |
| const dayOfMonth = date.getDate(); | |
| calendarHtml.push(` | |
| <div class="calendar-day ${isToday ? "active" : ""}" | |
| onclick="sidebar.filterByDay(${i})" | |
| title="${date.toLocaleDateString()} - ${count} articles" | |
| role="button" | |
| tabindex="0" | |
| aria-pressed="${isToday}" | |
| aria-label="${dayName} ${dayOfMonth}, ${count} articles" | |
| onkeydown="if(event.key==='Enter'||event.key===' '){event.preventDefault();sidebar.filterByDay(${i})}"> | |
| <span class="day-name">${dayName}</span> | |
| <span class="day-date">${dayOfMonth}</span> | |
| <span class="day-count ${count === 0 ? "zero" : ""}">${count}</span> | |
| </div> | |
| `); | |
| } | |
| this.elements.calendarGrid.innerHTML = calendarHtml.join(""); | |
| } | |
| /** | |
| * Get a normalized date key (YYYY-MM-DD) for consistent comparison | |
| */ | |
| getDateKey(date) { | |
| const d = new Date(date); | |
| return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; | |
| } | |
| filterByDay(daysAgo) { | |
| const today = new Date(); | |
| today.setHours(0, 0, 0, 0); | |
| const targetDate = new Date(today); | |
| targetDate.setDate(targetDate.getDate() - daysAgo); | |
| const targetDateKey = this.getDateKey(targetDate); | |
| // Filter articles from that day using consistent date key | |
| const dayArticles = this.allArticles.filter( | |
| article => article.pubDate && this.getDateKey(article.pubDate) === targetDateKey | |
| ); | |
| if (dayArticles.length === 0) { | |
| this.elements.searchResults.innerHTML = `<span class="search-hint">No articles from ${targetDate.toLocaleDateString()}</span>`; | |
| } else { | |
| this.elements.searchResults.innerHTML = dayArticles | |
| .slice(0, 20) | |
| .map( | |
| article => ` | |
| <a class="search-result-item" href="${this.escapeHtml(article.link)}" target="_blank" rel="noopener"> | |
| ${this.escapeHtml(this.truncate(article.title, 80))} | |
| <div class="search-result-source">${article.sourceIcon} ${this.escapeHtml(article.sourceName)}</div> | |
| </a> | |
| ` | |
| ) | |
| .join(""); | |
| } | |
| this.elements.searchInput.value = ""; | |
| // Update calendar active state and aria-pressed | |
| document.querySelectorAll(".calendar-day").forEach((day, index) => { | |
| const isActive = index === 6 - daysAgo; | |
| day.classList.toggle("active", isActive); | |
| day.setAttribute("aria-pressed", isActive); | |
| }); | |
| } | |
| /* ============================================ | |
| UTILITIES | |
| ============================================ */ | |
| 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); | |
| } | |
| truncate(text, maxLength) { | |
| return window.IvyUtils.truncate(text, maxLength); | |
| } | |
| } | |
| // Export | |
| window.SidebarManager = SidebarManager; | |