/* ============================================
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 => `
`
)
.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 => `
${source.icon}
${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;