/** * News Page - Crypto News Feed with News API Integration */ import { NEWS_CONFIG } from './news-config.js'; class NewsPage { constructor() { this.articles = []; this.allArticles = []; this.refreshInterval = null; this.isLoading = false; this.currentFilters = { keyword: '', source: '', sentiment: '' }; this.config = NEWS_CONFIG; } async init() { try { console.log('[News] Initializing...'); this.bindEvents(); await this.loadNews(); // Auto-refresh based on config if (this.config.autoRefreshInterval > 0) { this.refreshInterval = setInterval(() => { if (!this.isLoading) { this.loadNews(); } }, this.config.autoRefreshInterval); } this.showToast('News loaded', 'success'); } catch (error) { console.error('[News] Init error:', error); } } /** * Cleanup on page unload */ destroy() { if (this.refreshInterval) { clearInterval(this.refreshInterval); this.refreshInterval = null; } } bindEvents() { // Refresh button document.getElementById('refresh-btn')?.addEventListener('click', () => { this.loadNews(); }); // Search functionality - debounced let searchTimeout; document.getElementById('search-input')?.addEventListener('input', (e) => { clearTimeout(searchTimeout); searchTimeout = setTimeout(() => { this.currentFilters.keyword = e.target.value.trim(); this.applyFilters(); }, 300); }); // Source filter document.getElementById('source-select')?.addEventListener('change', (e) => { this.currentFilters.source = e.target.value; this.applyFilters(); }); // Sentiment filter document.getElementById('sentiment-select')?.addEventListener('change', (e) => { this.currentFilters.sentiment = e.target.value; this.applyFilters(); }); // Summarize button document.getElementById('summarize-btn')?.addEventListener('click', () => { this.summarizeNews(); }); } /** * Load news from News API with comprehensive error handling * @param {boolean} forceRefresh - Skip cache and fetch fresh data */ async loadNews(forceRefresh = false) { if (this.isLoading) { return; } this.isLoading = true; try { let data = []; try { data = await this.fetchFromNewsAPI(); } catch (error) { console.error('[News] News API request failed:', error); this.handleAPIError(error); } if (data.length === 0) { console.warn('[News] No articles from API'); this.showToast('No news articles available. Please try again later.', 'warning'); } else { this.showToast(`Loaded ${data.length} articles`, 'success'); } this.allArticles = [...data]; this.applyFilters(); this.populateSourceDropdown(); this.updateTimestamp(); } catch (error) { console.error('[News] Load error:', error); this.articles = []; this.allArticles = []; this.renderNews(); this.showToast('Error loading news. Please check your connection.', 'error'); } finally { this.isLoading = false; } } /** * Fetch news articles from backend API * @returns {Promise} Array of formatted news articles */ async fetchFromNewsAPI() { try { // Try backend API first const limit = this.config.pageSize || 50; let response = await fetch(`/api/news?limit=${limit}`, { method: 'GET', headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) }); if (response.ok) { const data = await response.json(); // Handle different response formats let articles = []; if (data.news && Array.isArray(data.news)) { // Backend returns { success, news, count } articles = data.news; } else if (data.articles && Array.isArray(data.articles)) { articles = data.articles; } else if (data.data && Array.isArray(data.data)) { articles = data.data; } else if (Array.isArray(data)) { articles = data; } if (articles.length > 0) { return this.formatBackendNewsArticles(articles); } } // Fallback: Try alternative endpoint response = await fetch(`/api/news/latest?limit=${limit}`, { method: 'GET', headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) }); if (response.ok) { const data = await response.json(); let articles = []; if (data.articles && Array.isArray(data.articles)) { articles = data.articles; } else if (data.data && Array.isArray(data.data)) { articles = data.data; } else if (Array.isArray(data)) { articles = data; } if (articles.length > 0) { return this.formatBackendNewsArticles(articles); } } throw new Error('No articles found from backend API'); } catch (error) { console.warn('[News] Backend API failed, trying direct News API:', error); // Fallback to direct News API if backend fails const searchQuery = this.currentFilters.keyword || this.config.defaultQuery; const fromDate = new Date(); fromDate.setDate(fromDate.getDate() - this.config.daysBack); const params = new URLSearchParams({ q: searchQuery, from: fromDate.toISOString().split('T')[0], sortBy: 'publishedAt', language: this.config.language, pageSize: this.config.pageSize, apiKey: this.config.apiKey }); const url = `${this.config.baseUrl}/everything?${params.toString()}`; try { const response = await fetch(url, { method: 'GET', headers: { 'Accept': 'application/json' }, signal: AbortSignal.timeout(10000) }); if (!response.ok) { throw new Error(`News API request failed: ${response.status}`); } const data = await response.json(); if (data.status === 'error') { throw new Error(data.message || 'API returned error status'); } if (!data.articles || !Array.isArray(data.articles)) { throw new Error('Invalid API response format'); } return this.formatNewsAPIArticles(data.articles); } catch (fallbackError) { if (fallbackError.name === 'TypeError' && fallbackError.message.includes('fetch')) { throw new Error('No internet connection'); } throw fallbackError; } } } /** * Format backend API articles to internal format * @param {Array} articles - Raw articles from backend API * @returns {Array} Formatted articles */ formatBackendNewsArticles(articles) { return articles .filter(article => article.title && article.title !== '[Removed]') .map(article => ({ title: article.title, content: article.description || article.content || article.summary || article.body || 'No description available', body: article.description || article.content || article.summary || article.body, source: { title: article.source?.name || article.source?.title || article.source || 'Unknown Source' }, published_at: article.publishedAt || article.published_at || article.created_at, url: article.url || '#', urlToImage: article.urlToImage || article.image || '', author: article.author || '', sentiment: article.sentiment || this.analyzeSentiment(article.title + ' ' + (article.description || article.content || '')), category: article.category || 'crypto' })); } /** * Format News API articles to internal format * @param {Array} articles - Raw articles from News API * @returns {Array} Formatted articles */ formatNewsAPIArticles(articles) { return articles .filter(article => article.title && article.title !== '[Removed]') .map(article => ({ title: article.title, content: article.description || article.content || 'No description available', body: article.description, source: { title: article.source?.name || 'Unknown Source' }, published_at: article.publishedAt, url: article.url, urlToImage: article.urlToImage, author: article.author, sentiment: this.analyzeSentiment(article.title + ' ' + (article.description || '')), category: 'crypto' })); } /** * Simple sentiment analysis based on keywords * @param {string} text - Text to analyze * @returns {string} Sentiment: 'positive', 'negative', or 'neutral' */ analyzeSentiment(text) { if (!text) return 'neutral'; const lowerText = text.toLowerCase(); const { positive: positiveWords, negative: negativeWords } = this.config.sentimentKeywords; let positiveCount = 0; let negativeCount = 0; positiveWords.forEach(word => { if (lowerText.includes(word)) positiveCount++; }); negativeWords.forEach(word => { if (lowerText.includes(word)) negativeCount++; }); if (positiveCount > negativeCount) return 'positive'; if (negativeCount > positiveCount) return 'negative'; return 'neutral'; } /** * Handle API errors with user-friendly messages * @param {Error} error - The error object */ handleAPIError(error) { const errorMessages = { 'Invalid API key': 'API authentication failed. Please check your API key.', 'API rate limit exceeded': 'Too many requests. Please try again later.', 'News API server error': 'News service is temporarily unavailable.', 'No internet connection': 'No internet connection. Please check your network.', }; const message = errorMessages[error.message] || `Error: ${error.message}`; this.showToast(message, 'error'); console.error('[News API Error]:', error); } // REMOVED: getDemoNews() - No demo data allowed, only real data from APIs /** * Apply all current filters to articles */ applyFilters() { let filtered = [...this.allArticles]; // Keyword search (client-side) if (this.currentFilters.keyword) { const keyword = this.currentFilters.keyword.toLowerCase(); filtered = filtered.filter(article => article.title?.toLowerCase().includes(keyword) || article.content?.toLowerCase().includes(keyword) || article.body?.toLowerCase().includes(keyword) ); } // Source filter (client-side as backup) if (this.currentFilters.source) { filtered = filtered.filter(article => { const sourceTitle = article.source?.title || article.source || ''; return sourceTitle === this.currentFilters.source; }); } // Sentiment filter (client-side as backup) if (this.currentFilters.sentiment) { filtered = filtered.filter(article => article.sentiment === this.currentFilters.sentiment ); } this.articles = filtered; this.renderNews(); this.updateStats(); } /** * Populate source dropdown with available sources */ populateSourceDropdown() { const sourceSelect = document.getElementById('source-select'); if (!sourceSelect) return; const sources = new Set(); this.allArticles.forEach(article => { const source = article.source?.title || article.source; if (source) sources.add(source); }); const currentValue = sourceSelect.value; sourceSelect.innerHTML = ''; Array.from(sources).sort().forEach(source => { const option = document.createElement('option'); option.value = source; option.textContent = source; sourceSelect.appendChild(option); }); if (currentValue) { sourceSelect.value = currentValue; } } async summarizeNews() { this.showToast('AI summarization coming soon!', 'info'); } /** * Update statistics display */ updateStats() { const stats = { total: this.articles.length, positive: 0, neutral: 0, negative: 0 }; this.articles.forEach(article => { if (article.sentiment === 'positive') stats.positive++; else if (article.sentiment === 'negative') stats.negative++; else stats.neutral++; }); const totalEl = document.getElementById('total-articles'); if (totalEl) totalEl.textContent = stats.total; const positiveEl = document.getElementById('positive-count'); if (positiveEl) positiveEl.textContent = stats.positive; const neutralEl = document.getElementById('neutral-count'); if (neutralEl) neutralEl.textContent = stats.neutral; const negativeEl = document.getElementById('negative-count'); if (negativeEl) negativeEl.textContent = stats.negative; } /** * Render news articles to the DOM with enhanced formatting */ renderNews() { const container = document.getElementById('news-container') || document.getElementById('news-grid') || document.getElementById('news-list'); if (!container) { console.error('[News] Container not found'); return; } if (this.articles.length === 0) { container.innerHTML = `
📰

No news articles found

No articles match your current filters. Try adjusting your search or filters.

`; return; } container.innerHTML = this.articles.map((article, index) => { const sentimentBadge = article.sentiment ? `${article.sentiment}` : ''; const imageSection = article.urlToImage ? `
${this.escapeHtml(article.title)}
` : ''; const author = article.author ? ` ${this.escapeHtml(article.author)} ` : ''; return `
${imageSection}

${this.escapeHtml(article.title || 'Crypto News Update')}

${this.formatTime(article.published_at || article.created_at)}

${this.escapeHtml(article.content || article.body || 'Latest cryptocurrency market news and updates.')}

`; }).join(''); } /** * Escape HTML to prevent XSS * @param {string} str - String to escape * @returns {string} Escaped string */ escapeHtml(str) { if (!str) return ''; const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } formatTime(dateStr) { if (!dateStr) return 'Recently'; const date = new Date(dateStr); const now = new Date(); const diffMs = now - date; const diffMins = Math.floor(diffMs / 60000); const diffHours = Math.floor(diffMins / 60); if (diffMins < 60) return `${diffMins}m ago`; if (diffHours < 24) return `${diffHours}h ago`; return date.toLocaleDateString(); } updateTimestamp() { const el = document.getElementById('last-update'); if (el) { el.textContent = `Updated: ${new Date().toLocaleTimeString()}`; } } showToast(message, type = 'info') { const colors = { success: '#22c55e', error: '#ef4444', info: '#3b82f6', warning: '#f59e0b' }; const toast = document.createElement('div'); toast.style.cssText = ` position: fixed; top: 20px; right: 20px; padding: 12px 20px; border-radius: 8px; background: ${colors[type] || colors.info}; color: white; font-weight: 500; z-index: 9999; box-shadow: 0 4px 12px rgba(0,0,0,0.3); animation: slideIn 0.3s ease; `; toast.textContent = message; document.body.appendChild(toast); setTimeout(() => { toast.style.animation = 'slideOut 0.3s ease'; setTimeout(() => toast.remove(), 300); }, 3000); } } const newsPage = new NewsPage(); window.newsPage = newsPage; // Make available globally for cleanup newsPage.init(); export default newsPage;