|
|
|
|
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
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);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
destroy() {
|
|
|
if (this.refreshInterval) {
|
|
|
clearInterval(this.refreshInterval);
|
|
|
this.refreshInterval = null;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
bindEvents() {
|
|
|
|
|
|
document.getElementById('refresh-btn')?.addEventListener('click', () => {
|
|
|
this.loadNews();
|
|
|
});
|
|
|
|
|
|
|
|
|
let searchTimeout;
|
|
|
document.getElementById('search-input')?.addEventListener('input', (e) => {
|
|
|
clearTimeout(searchTimeout);
|
|
|
searchTimeout = setTimeout(() => {
|
|
|
this.currentFilters.keyword = e.target.value.trim();
|
|
|
this.applyFilters();
|
|
|
}, 300);
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById('source-select')?.addEventListener('change', (e) => {
|
|
|
this.currentFilters.source = e.target.value;
|
|
|
this.applyFilters();
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById('sentiment-select')?.addEventListener('change', (e) => {
|
|
|
this.currentFilters.sentiment = e.target.value;
|
|
|
this.applyFilters();
|
|
|
});
|
|
|
|
|
|
|
|
|
document.getElementById('summarize-btn')?.addEventListener('click', () => {
|
|
|
this.summarizeNews();
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
async fetchFromNewsAPI() {
|
|
|
try {
|
|
|
|
|
|
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();
|
|
|
|
|
|
|
|
|
let articles = [];
|
|
|
if (data.news && Array.isArray(data.news)) {
|
|
|
|
|
|
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);
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
}
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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'
|
|
|
}));
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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';
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
applyFilters() {
|
|
|
let filtered = [...this.allArticles];
|
|
|
|
|
|
|
|
|
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)
|
|
|
);
|
|
|
}
|
|
|
|
|
|
|
|
|
if (this.currentFilters.source) {
|
|
|
filtered = filtered.filter(article => {
|
|
|
const sourceTitle = article.source?.title || article.source || '';
|
|
|
return sourceTitle === this.currentFilters.source;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
|
|
|
if (this.currentFilters.sentiment) {
|
|
|
filtered = filtered.filter(article =>
|
|
|
article.sentiment === this.currentFilters.sentiment
|
|
|
);
|
|
|
}
|
|
|
|
|
|
this.articles = filtered;
|
|
|
this.renderNews();
|
|
|
this.updateStats();
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = '<option value="">All Sources</option>';
|
|
|
|
|
|
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');
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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 = `
|
|
|
<div class="empty-state glass-card">
|
|
|
<div class="empty-icon">📰</div>
|
|
|
<h3>No news articles found</h3>
|
|
|
<p>No articles match your current filters. Try adjusting your search or filters.</p>
|
|
|
<button class="btn-gradient" onclick="window.newsPage.loadNews(true)">
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polyline points="23 4 23 10 17 10"></polyline><polyline points="1 20 1 14 7 14"></polyline><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"></path></svg>
|
|
|
Reload News
|
|
|
</button>
|
|
|
</div>
|
|
|
`;
|
|
|
return;
|
|
|
}
|
|
|
|
|
|
container.innerHTML = this.articles.map((article, index) => {
|
|
|
const sentimentBadge = article.sentiment ?
|
|
|
`<span class="sentiment-badge sentiment-${article.sentiment}">${article.sentiment}</span>` : '';
|
|
|
|
|
|
const imageSection = article.urlToImage ? `
|
|
|
<div class="news-image-container">
|
|
|
<img src="${this.escapeHtml(article.urlToImage)}"
|
|
|
alt="${this.escapeHtml(article.title)}"
|
|
|
class="news-image"
|
|
|
loading="lazy"
|
|
|
onerror="this.style.display='none'">
|
|
|
</div>
|
|
|
` : '';
|
|
|
|
|
|
const author = article.author ? `
|
|
|
<span class="news-author" title="Author">
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"></path><circle cx="12" cy="7" r="4"></circle></svg>
|
|
|
${this.escapeHtml(article.author)}
|
|
|
</span>
|
|
|
` : '';
|
|
|
|
|
|
return `
|
|
|
<div class="news-card glass-card" style="animation-delay: ${index * 0.05}s">
|
|
|
${imageSection}
|
|
|
<div class="news-content">
|
|
|
<div class="news-header">
|
|
|
<h3 class="news-title">${this.escapeHtml(article.title || 'Crypto News Update')}</h3>
|
|
|
<span class="news-time">${this.formatTime(article.published_at || article.created_at)}</span>
|
|
|
</div>
|
|
|
<p class="news-body">${this.escapeHtml(article.content || article.body || 'Latest cryptocurrency market news and updates.')}</p>
|
|
|
<div class="news-footer">
|
|
|
<div class="news-meta">
|
|
|
<span class="news-source">
|
|
|
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 22h16a2 2 0 0 0 2-2V4a2 2 0 0 0-2-2H8a2 2 0 0 0-2 2v16a2 2 0 0 1-2 2Zm0 0a2 2 0 0 1-2-2v-9c0-1.1.9-2 2-2h2"></path></svg>
|
|
|
${this.escapeHtml(article.source?.title || article.source || 'CryptoNews')}
|
|
|
</span>
|
|
|
${author}
|
|
|
${sentimentBadge}
|
|
|
</div>
|
|
|
${article.url && article.url !== '#' ? `
|
|
|
<a href="${this.escapeHtml(article.url)}" target="_blank" rel="noopener noreferrer" class="news-link">
|
|
|
Read Full Article →
|
|
|
</a>
|
|
|
` : ''}
|
|
|
</div>
|
|
|
</div>
|
|
|
</div>
|
|
|
`;
|
|
|
}).join('');
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
newsPage.init();
|
|
|
|
|
|
export default newsPage;
|
|
|
|