/* ============================================ 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 = 'Type at least 2 characters to search...'; return; } // Show loading indicator immediately for better UX this.elements.searchResults.innerHTML = ' Searching...'; // Guard against empty allArticles if (!this.allArticles || this.allArticles.length === 0) { this.elements.searchResults.innerHTML = 'No articles loaded yet. Please wait...'; 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 = 'No results found for "' + this.escapeHtml(query) + '"'; return; } // Show count in results header const countText = results.length === 20 ? "20+ results" : `${results.length} result${results.length > 1 ? "s" : ""}`; this.elements.searchResults.innerHTML = `
${countText} for "${this.escapeHtml(query)}"
` + results .map( article => ` ${this.highlightMatch(article.title, query)}
${article.sourceIcon} ${this.escapeHtml(article.sourceName)}
` ) .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, "$1"); } 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 = 'No bookmarks yet. Click ⭐ on articles to save them.'; return; } this.elements.bookmarksList.innerHTML = this.bookmarks .map( bookmark => `
${this.escapeHtml(this.truncate(bookmark.title, 60))}
` ) .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 = 'No trending topics yet.'; return; } const maxCount = trending[0][1]; const html = trending .map(([word, count]) => { const isHot = count >= maxCount * 0.7; return ` `; }) .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 = 'No favorite sources. Click ⭐ on source headers.'; 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 => `
${this.escapeHtml(source.name)} ${sourceCounts[source.id] || 0}
` ) .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(`
${dayName} ${dayOfMonth} ${count}
`); } 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 = `No articles from ${targetDate.toLocaleDateString()}`; } else { this.elements.searchResults.innerHTML = dayArticles .slice(0, 20) .map( article => ` ${this.escapeHtml(this.truncate(article.title, 80))}
${article.sourceIcon} ${this.escapeHtml(article.sourceName)}
` ) .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;